From 14aa3550d61d6d152424f5b5718306a529011ef6 Mon Sep 17 00:00:00 2001 From: jackyalbo Date: Sun, 20 Apr 2025 18:00:45 +0300 Subject: [PATCH] LDAP Support - Part 1 1. ldap_client 2. support assume_role_with_web_identity Signed-off-by: jackyalbo --- config.js | 5 + docs/design/ldap.md | 149 +++++++++++++++++ package-lock.json | 121 ++++++++++++++ package.json | 1 + src/endpoint/endpoint.js | 40 +++-- src/endpoint/sts/ops/sts_post_assume_role.js | 52 +----- .../sts_post_assume_role_with_web_identity.js | 73 +++++++++ src/endpoint/sts/sts_errors.js | 17 +- src/endpoint/sts/sts_rest.js | 5 +- src/endpoint/sts/sts_utils.js | 55 +++++++ src/sdk/sts_sdk.js | 81 +++++++-- .../integration_tests/api/sts/test_sts.js | 65 +++++++- src/util/ldap_client.js | 155 ++++++++++++++++++ 13 files changed, 739 insertions(+), 80 deletions(-) create mode 100644 docs/design/ldap.md create mode 100644 src/endpoint/sts/ops/sts_post_assume_role_with_web_identity.js create mode 100644 src/endpoint/sts/sts_utils.js create mode 100644 src/util/ldap_client.js diff --git a/config.js b/config.js index 7396009a56..6330c993a5 100644 --- a/config.js +++ b/config.js @@ -77,6 +77,11 @@ config.IAM_SERVICE_CERT_PATH = '/etc/iam-secret'; config.MGMT_SERVICE_CERT_PATH = '/etc/mgmt-secret'; config.EXTERNAL_DB_SERVICE_CERT_PATH = '/etc/external-db-secret'; +///////////////// +// LDAP CONFIG // +///////////////// +config.LDAP_CONFIG_PATH = '/etc/noobaa-server/ldap_config'; + ////////////////// // NODES CONFIG // ////////////////// diff --git a/docs/design/ldap.md b/docs/design/ldap.md new file mode 100644 index 0000000000..e97457024c --- /dev/null +++ b/docs/design/ldap.md @@ -0,0 +1,149 @@ + +# User Database support (LDAP) - POC + + +## Goal + +We propose adding support for administrators who maintain an existing LDAP-compatible user directory, enabling them to reuse their current username and password credentials to obtain temporary access tokens for S3 operations. +This feature will leverage the AWS STS operation AssumeRoleWithWebIdentity. In the proposed workflow: +1. The client sends a signed(with a predfined constant signature) or unsigned JWT as the web identity token. +2. The system parses the JWT to extract the LDAP username and password. +3. These credentials are validated against a configured external LDAP server. +4. Upon successful authentication, the system issues a temporary STS token, granting the user access to S3 resources for a limited duration. + +This approach ensures secure integration with existing identity infrastructures while eliminating the need to store or manage separate S3 credentials for LDAP users. + +## Configuring the external LDAP +The administrator must store the LDAP configuration in the following file: +/etc/noobaa-server/ldap_config + +The configuration should include: + +uri (Required) – The FQDN of the external LDAP server, in the format: +ldaps://[server-ip-or-hostname]:[port] (e.g., port 636 for LDAPS) + +admin (Required) – An administrator username with permission to execute search queries on the LDAP server. + +secret (Required) – The password for the administrator account. + +search_dn (Required) – The distinguished name (DN) under which search queries will be performed. + +dn_attribute (Optional) – The DN attribute to be used in search queries (default: uid). + +search_scope (Optional) – Determines how deep the LDAP search should go from the search_dn (default: sub): + +* base – Search only the entry specified by search_dn. + +* one – Search immediate children of search_dn, but not deeper levels. + +* sub – Search the base DN and all its descendants recursively. + +jwt_secret (Optional) - The JWT secret the administrator will use to sign the token sent in AssumeRoleWithWebIdentity requests (default: unsigned). Once this option is set - unsigned tokens or tokens signed with different secret will be dropped as access denied. + +for example: +```json +{ + "uri": "ldap://ldap.example.com:636", + "admin": "cn=admin,dc=example,dc=com", + "secret": "SuperSecurePassword123", + "search_dn": "ou=users,dc=example,dc=com", + "dn_attribute": "uid", + "search_scope": "sub", + "jwt_secret": "IAMTHEADMIN123!(SHOULDBE256BITS)" +} +``` + +## Sending an AssumeRoleWithWebIdentity request +First create the json to be decoded: +```json +{ + "user": "TheUserName", + "password": "TheUserPassword", + "type": "ldap" +} +``` +Sign it +node.js example: +```js +const jwt = require('jsonwebtoken'); +console.log(jwt.sign({ user: "TheUserName", password: "TheUserPassword", type: "ldap" }, "IAMTHEADMIN123!(SHOULDBE256BITS)")); +eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiVGhlVXNlck5hbWUiLCJwYXNzd29yZCI6IlRoZVVzZXJQYXNzd29yZCIsInR5cGUiOiJsZGFwIiwiaWF0IjoxNzU1MTgxOTE3fQ. +``` +Or you can use 'none' algorithm if you are not interested with verifying the token +```js +const jwt = require('jsonwebtoken'); +console.log(jwt.sign({ user: "TheUserName", password: "TheUserPassword", type: "ldap" }, undefined, { algorithm: 'none' })); +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiVGhlVXNlck5hbWUiLCJwYXNzd29yZCI6IlRoZVVzZXJQYXNzd29yZCIsInR5cGUiOiJsZGFwIiwiaWF0IjoxNzU1MTgyMzQ2fQ.P6WYcdM0kJagNK4D0M8AHiGFcUZ-DhTOKHlC1-AxcT0 +``` +Now use this token with AWS STS AssumeRoleWithWebIdentity: +```bash +aws sts assume-role-with-web-identity --endpoint [endpoint] --role-arn [role-arn] --role-session-name [session-name] --web-identity-token eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiVGhlVXNlck5hbWUiLCJwYXNzd29yZCI6IlRoZVVzZXJQYXNzd29yZCIsInR5cGUiOiJsZGFwIiwiaWF0IjoxNzU1MTgxOTE3fQ. +``` +role-name - ARN of the role that the caller is assuming. Make sure you created this role in advance using account-api or noobaa-cli (See Appendix. A). Format is: `arn:aws:sts::[user-access-key]:role/[role-name]` + +user-access-key - the access key of the account (temp secret key and token will be returned by the call) + +session-name - An identifier for the assumed role session. Typically, you pass the name or identifier that is associated with the user who is using your application. + +Output: The temporary security credentials, which include an access key ID, a secret access key, and a security token. + +read more here: https://docs.aws.amazon.com/cli/latest/reference/sts/assume-role-with-web-identity.html + +## Next steps + +1. See if we can move to using C open-ldap client as part of our native code instead of ldapts (for better performance and fewer security issues). see here: https://www.openldap.org/software/repo.html (We will mainly need bind and search) +2. Add a system test that will create an external ldap and will check the full authentication flow. You can base on dockers I used for testing: +* AD image: `docker run --rm --privileged -p 636:636 quay.io/samba.org/samba-ad-server:latest` (https://github.com/samba-in-kubernetes/samba-container) +* LDAP image: `docker run --rm --privileged -p 636:636 ghcr.io/ldapjs/docker-test-openldap/openldap:latest` (https://github.com/ldapjs/docker-test-openldap/pkgs/container/docker-test-openldap%2Fopenldap) +3. Add support to the operator side: +* CLI command for configuring external LDAP +* Create K8s Secret for LDAP info and mount to /etc/noobaa-server/ldap_config to the relevant pods +4. Better align and adapt to the IAM effort also in POC stage +5. See if we want to support encrypted password as part of the JWT token. see here: https://auth0.com/docs/secure/tokens/access-tokens/json-web-encryption +6. We should maybe move ldap authentication to the authentication scope if possible + +## Appendix A: Creating account w/ role config using NooBaa API: +```bash +curl http://127.0.0.1:5001/rpc/ -sd '{ + "api": "account_api", + "method": "create_account", + "params": { + "name": "ldap", + "email": "ldap", + "has_login": false, + "s3_access": true, + "role_config": + { + "role_name": "ldap_user", + "assume_role_policy": + { + "statement": [ + { + "effect": "allow", + "action": ["sts:*"], + "principal": ["*"] + }] + } + } + }, + "auth_token": "'$(cat .nbtoken)'" +}' +``` +in order to assume this role: +```bash +aws sts assume-role-with-web-identity --endpoint https://127.0.0.1:7443 --role-arn arn:aws:sts::pQII1cm5kFmpwqP6bzJh:role/ldap_user --role-session-name fry1 --web-identity-token eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiZnJ5IiwicGFzc3dvcmQiOiJmcnkiLCJ0eXBlIjoibGRhcCIsImlhdCI6MTc1NTQzMzkxOX0. --no-verify-ssl +{ + "Credentials": { + "AccessKeyId": "", + "SecretAccessKey": "", + "SessionToken": "", + "Expiration": "2025-08-17T14:03:18+00:00" + }, + "AssumedRoleUser": { + "AssumedRoleId": "pQII1cm5kFmpwqP6bzJh:fry1", + "Arn": "arn:aws:sts::pQII1cm5kFmpwqP6bzJh:assumed-role/ldap_user/fry1" + }, + "SourceIdentity": "cn=Philip J. Fry,ou=people,dc=planetexpress,dc=com" +} +``` + diff --git a/package-lock.json b/package-lock.json index 6ef070f362..f048b9d0e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "https-proxy-agent": "7.0.6", "ip": "2.0.1", "jsonwebtoken": "9.0.2", + "ldapts": "7.3.1", "linux-blockutils": "0.2.0", "lodash": "4.17.21", "mime-types": "3.0.1", @@ -4104,6 +4105,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5055,6 +5065,15 @@ "node": ">=8" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -9224,6 +9243,102 @@ "json-buffer": "3.0.1" } }, + "node_modules/ldapts": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.3.1.tgz", + "integrity": "sha512-g8mxobOSeuxVkXRT9JZBGUvfDjXIpQPEHH5kYG9UjrIlWV5Rqxq+MMmqzlSh4OqSXh+3lFvzyYu+lsJldoZvvA==", + "license": "MIT", + "dependencies": { + "@types/asn1": ">=0.2.4", + "asn1": "~0.2.6", + "debug": "~4.4.0", + "strict-event-emitter-types": "~2.0.0", + "uuid": "~11.0.4", + "whatwg-url": "~14.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ldapts/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ldapts/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ldapts/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ldapts/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ldapts/node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/ldapts/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/ldapts/node_modules/whatwg-url": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", + "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -12360,6 +12475,12 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index 401fb0db1a..69e629ec1d 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "jsonwebtoken": "9.0.2", "linux-blockutils": "0.2.0", "lodash": "4.17.21", + "ldapts": "7.3.1", "mime-types": "3.0.1", "minimist": "1.2.8", "moment": "2.30.1", diff --git a/src/endpoint/endpoint.js b/src/endpoint/endpoint.js index 8787a40ad7..f57ac2c7a3 100755 --- a/src/endpoint/endpoint.js +++ b/src/endpoint/endpoint.js @@ -41,10 +41,12 @@ const { SemaphoreMonitor } = require('../server/bg_services/semaphore_monitor'); const prom_reporting = require('../server/analytic_services/prometheus_reporting'); const { PersistentLogger } = require('../util/persistent_logger'); const { get_notification_logger } = require('../util/notifications_util'); +const ldap_client = require('../util/ldap_client'); const { is_nc_environment } = require('../nc/nc_utils'); const NoobaaEvent = require('../manage_nsfs/manage_nsfs_events_utils').NoobaaEvent; const cluster = /** @type {import('node:cluster').Cluster} */ ( - /** @type {unknown} */ (require('node:cluster')) + /** @type {unknown} */ + (require('node:cluster')) ); if (process.env.NOOBAA_LOG_LEVEL) { @@ -115,16 +117,16 @@ async function main(options = {}) { const http_metrics_port = options.http_metrics_port || config.EP_METRICS_SERVER_PORT; const https_metrics_port = options.https_metrics_port || config.EP_METRICS_SERVER_SSL_PORT; /** - * Please notice that we can run the main in 2 states: - * 1. Only the primary process runs the main (fork is 0 or undefined) - everything that - * is implemented here would be run by this process. - * 2. A primary process with multiple forks (IMPORTANT) - if there is implementation that - * in only relevant to the primary process it should be implemented in - * fork_utils.start_workers because the primary process returns after start_workers - * and the forks will continue executing the code lines in this function - * */ + * Please notice that we can run the main in 2 states: + * 1. Only the primary process runs the main (fork is 0 or undefined) - everything that + * is implemented here would be run by this process. + * 2. A primary process with multiple forks (IMPORTANT) - if there is implementation that + * in only relevant to the primary process it should be implemented in + * fork_utils.start_workers because the primary process returns after start_workers + * and the forks will continue executing the code lines in this function + * */ const is_workers_started_from_primary = await fork_utils.start_workers(http_metrics_port, https_metrics_port, - options.nsfs_config_root, fork_count); + options.nsfs_config_root, fork_count); if (is_workers_started_from_primary) return; const endpoint_group_id = process.env.ENDPOINT_GROUP_ID || 'default-endpoint-group'; @@ -197,8 +199,14 @@ async function main(options = {}) { const https_port_sts = options.https_port_sts || config.ENDPOINT_SSL_STS_PORT; const https_port_iam = options.https_port_iam || config.ENDPOINT_SSL_IAM_PORT; - await start_endpoint_server_and_cert(SERVICES_TYPES_ENUM.S3, init_request_sdk, - { ...options, https_port: https_port_s3, http_port: http_port_s3, virtual_hosts, bucket_logger, notification_logger }); + await start_endpoint_server_and_cert(SERVICES_TYPES_ENUM.S3, init_request_sdk, { + ...options, + https_port: https_port_s3, + http_port: http_port_s3, + virtual_hosts, + bucket_logger, + notification_logger + }); await start_endpoint_server_and_cert(SERVICES_TYPES_ENUM.STS, init_request_sdk, { https_port: https_port_sts, virtual_hosts }); await start_endpoint_server_and_cert(SERVICES_TYPES_ENUM.IAM, init_request_sdk, { https_port: https_port_iam }); const is_nc = is_nc_environment(); @@ -210,11 +218,11 @@ async function main(options = {}) { nsfs_config_root: options.nsfs_config_root, health_port: config.ENDPOINT_FORK_PORT_BASE }); - // current process is a worker so we listen to get the port from the primary process. + // current process is a worker so we listen to get the port from the primary process. } else { process.on('message', fork_message_request_handler); //send a message to the primary process that we are ready to receive messages - process.send({ready_to_start_fork_server: true}); + process.send({ ready_to_start_fork_server: true }); } } @@ -241,6 +249,10 @@ async function main(options = {}) { object_io: object_io, })); } + + if (await ldap_client.is_ldap_configured()) { + ldap_client.instance().connect(); + } //noobaa started new NoobaaEvent(NoobaaEvent.NOOBAA_STARTED).create_event(undefined, undefined, undefined); // Start a monitor to send periodic endpoint reports about endpoint usage. diff --git a/src/endpoint/sts/ops/sts_post_assume_role.js b/src/endpoint/sts/ops/sts_post_assume_role.js index e1135299ea..f1d5746835 100644 --- a/src/endpoint/sts/ops/sts_post_assume_role.js +++ b/src/endpoint/sts/ops/sts_post_assume_role.js @@ -3,17 +3,16 @@ const dbg = require('../../../util/debug_module')(__filename); const { StsError } = require('../sts_errors'); -const jwt_utils = require('../../../util/jwt_utils'); -const config = require('../../../../config'); const { CONTENT_TYPE_APP_FORM_URLENCODED } = require('../../../util/http_utils'); const s3_utils = require('../../s3/s3_utils'); +const sts_utils = require('../../sts/sts_utils'); /** * https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html */ async function assume_role(req) { dbg.log1('sts_post_assume_role body: ', req.body); - const duration_ms = _parse_sts_duration(req.body.duration_seconds); + const duration_ms = sts_utils.parse_sts_duration(req.body.duration_seconds); const duration_sec = Math.ceil(duration_ms / 1000); const expiration_time = Date.now() + duration_ms; let assumed_role; @@ -41,7 +40,7 @@ async function assume_role(req) { AccessKeyId: access_keys.access_key.unwrap(), SecretAccessKey: access_keys.secret_key.unwrap(), Expiration: s3_utils.format_s3_xml_date(expiration_time), - SessionToken: generate_session_token({ + SessionToken: sts_utils.generate_session_token({ access_key: access_keys.access_key.unwrap(), secret_key: access_keys.secret_key.unwrap(), assumed_role_access_key: assumed_role.access_key @@ -53,51 +52,6 @@ async function assume_role(req) { }; } -// create and return the signed token -/** - * @param {Object} auth_options - * @param {Number} expiry in seconds - * @returns {String} -*/ -function generate_session_token(auth_options, expiry) { - dbg.log1('sts_post_assume_role.make_session_token: ', auth_options, expiry); - return jwt_utils.make_auth_token(auth_options, { expiresIn: expiry }); -} - -// TODO: Generalize and move to a utils file in the future -/** - * @param {String|undefined} duration_input duration in seconds - * @returns {Number} duration in milliseconds - */ -function _parse_sts_duration(duration_input) { - if (duration_input === undefined) { - return config.STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS; - } - - const duration_sec = Number(duration_input); - - if (!Number.isInteger(duration_sec)) { - throw new StsError(StsError.InvalidParameterValue); - } - - if (duration_sec < config.STS_MIN_DURATION_SECONDS) { - throw new StsError(_sts_duration_validation_error(duration_input, 'greater', config.STS_MIN_DURATION_SECONDS)); - } - if (duration_sec > config.STS_MAX_DURATION_SECONDS) { - throw new StsError(_sts_duration_validation_error(duration_input, 'less', config.STS_MAX_DURATION_SECONDS)); - } - - const duration_ms = duration_sec * 1000; - return duration_ms; -} - -function _sts_duration_validation_error(duration_input, constraint, constraint_value) { - return { - ...StsError.ValidationError, - message: `Value ${duration_input} for durationSeconds failed to satisfy constraint: Member must have value ${constraint} than or equal to ${constraint_value}`, - }; -} - module.exports = { handler: assume_role, body: { diff --git a/src/endpoint/sts/ops/sts_post_assume_role_with_web_identity.js b/src/endpoint/sts/ops/sts_post_assume_role_with_web_identity.js new file mode 100644 index 0000000000..f19b8ea685 --- /dev/null +++ b/src/endpoint/sts/ops/sts_post_assume_role_with_web_identity.js @@ -0,0 +1,73 @@ +/* Copyright (C) 2016 NooBaa */ +'use strict'; + +const dbg = require('../../../util/debug_module')(__filename); +const { StsError } = require('../sts_errors'); +const { CONTENT_TYPE_APP_FORM_URLENCODED } = require('../../../util/http_utils'); +const s3_utils = require('../../s3/s3_utils'); +const sts_utils = require('../../sts/sts_utils'); +const _ = require('lodash'); + +/** + * https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html + */ +async function assume_role_with_web_identity(req) { + dbg.log0('sts_post_assume_role_with_web_identity body: ', _.omit(req.body, 'web_identity_token')); + const duration_ms = sts_utils.parse_sts_duration(req.body.duration_seconds); + const duration_sec = Math.ceil(duration_ms / 1000); + const expiration_time = Date.now() + duration_ms; + let assumed_role; + try { + assumed_role = await req.sts_sdk.get_assumed_ldap_user(req); + } catch (err) { + if (err.rpc_code === 'ACCESS_DENIED') { + throw new StsError(StsError.AccessDeniedException); + } + if (err.rpc_code === 'EXPIRED_WEB_IDENTITY_TOKEN') { + throw new StsError(StsError.ExpiredToken); + } + if (err.rpc_code === 'INVALID_WEB_IDENTITY_TOKEN') { + throw new StsError({ ...StsError.InvalidIdentityToken, message: err.message }); + } + dbg.error('get_assumed_ldap_user error:', err); + throw new StsError(StsError.InternalFailure); + } + // Temporary credentials are NOT stored in noobaa + // The generated session token will store in it the temporary credentials and expiry and the role's access key + const access_keys = await req.sts_sdk.generate_temp_access_keys(); + + return { + AssumeRoleWithWebIdentityResponse: { + AssumeRoleWithWebIdentityResult: { + SubjectFromWebIdentityToken: assumed_role.sub, + Audience: assumed_role.aud, + AssumedRoleUser: { + Arn: `arn:aws:sts::${assumed_role.access_key}:assumed-role/${assumed_role.role_config.role_name}/${req.body.role_session_name}`, + AssumedRoleId: `${assumed_role.access_key}:${req.body.role_session_name}` + }, + Credentials: { + AccessKeyId: access_keys.access_key.unwrap(), + SecretAccessKey: access_keys.secret_key.unwrap(), + Expiration: s3_utils.format_s3_xml_date(expiration_time), + SessionToken: sts_utils.generate_session_token({ + access_key: access_keys.access_key.unwrap(), + secret_key: access_keys.secret_key.unwrap(), + assumed_role_access_key: assumed_role.access_key + }, duration_sec) + }, + SourceIdentity: assumed_role.dn, + Provider: assumed_role.iss, + } + } + }; +} + +module.exports = { + handler: assume_role_with_web_identity, + body: { + type: CONTENT_TYPE_APP_FORM_URLENCODED, + }, + reply: { + type: 'xml', + }, +}; diff --git a/src/endpoint/sts/sts_errors.js b/src/endpoint/sts/sts_errors.js index 74140accf4..2848dbd6cf 100644 --- a/src/endpoint/sts/sts_errors.js +++ b/src/endpoint/sts/sts_errors.js @@ -26,12 +26,14 @@ class StsError extends Error { reply(resource, request_id) { const xml = { - Error: { - Code: this.code, - Message: this.message, - Resource: resource || '', + ErrorResponse: { + Error: { + Code: this.code, + Message: this.message, + Resource: resource || '', + Detail: this.detail, + }, RequestId: request_id || '', - Detail: this.detail, } }; return xml_utils.encode_xml(xml); @@ -139,4 +141,9 @@ StsError.ExpiredToken = Object.freeze({ message: 'The security token included in the request is expired', http_code: 400, }); +StsError.InvalidIdentityToken = Object.freeze({ + code: 'InvalidIdentityToken', + message: 'Missing a required claim', + http_code: 400, +}); exports.StsError = StsError; diff --git a/src/endpoint/sts/sts_rest.js b/src/endpoint/sts/sts_rest.js index 31720558eb..7022a876b5 100644 --- a/src/endpoint/sts/sts_rest.js +++ b/src/endpoint/sts/sts_rest.js @@ -25,15 +25,18 @@ const RPC_ERRORS_TO_STS = Object.freeze({ }); const ACTIONS = Object.freeze({ - 'AssumeRole': 'assume_role' + 'AssumeRole': 'assume_role', + 'AssumeRoleWithWebIdentity': 'assume_role_with_web_identity', }); const OP_NAME_TO_ACTION = Object.freeze({ post_assume_role: 'sts:AssumeRole', + post_assume_role_with_web_identity: 'sts:AssumeRoleWithWebIdentity', }); const STS_OPS = js_utils.deep_freeze({ post_assume_role: require('./ops/sts_post_assume_role'), + post_assume_role_with_web_identity: require('./ops/sts_post_assume_role_with_web_identity'), }); async function sts_rest(req, res) { diff --git a/src/endpoint/sts/sts_utils.js b/src/endpoint/sts/sts_utils.js new file mode 100644 index 0000000000..7e61eb2749 --- /dev/null +++ b/src/endpoint/sts/sts_utils.js @@ -0,0 +1,55 @@ +/* Copyright (C) 2016 NooBaa */ +'use strict'; + +const dbg = require('../../util/debug_module')(__filename); +const { StsError } = require('./sts_errors'); +const jwt_utils = require('../../util/jwt_utils'); +const config = require('../../../config'); + +// create and return the signed token +/** + * @param {Object} auth_options + * @param {Number} expiry in seconds + * @returns {String} + */ +function generate_session_token(auth_options, expiry) { + dbg.log1('sts_post_assume_role.make_session_token: ', auth_options, expiry); + return jwt_utils.make_auth_token(auth_options, { expiresIn: expiry }); +} + +// TODO: Generalize and move to a utils file in the future +/** + * @param {String|undefined} duration_input duration in seconds + * @returns {Number} duration in milliseconds + */ +function parse_sts_duration(duration_input) { + if (duration_input === undefined) { + return config.STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS; + } + + const duration_sec = Number(duration_input); + + if (!Number.isInteger(duration_sec)) { + throw new StsError(StsError.InvalidParameterValue); + } + + if (duration_sec < config.STS_MIN_DURATION_SECONDS) { + throw new StsError(_sts_duration_validation_error(duration_input, 'greater', config.STS_MIN_DURATION_SECONDS)); + } + if (duration_sec > config.STS_MAX_DURATION_SECONDS) { + throw new StsError(_sts_duration_validation_error(duration_input, 'less', config.STS_MAX_DURATION_SECONDS)); + } + + const duration_ms = duration_sec * 1000; + return duration_ms; +} + +function _sts_duration_validation_error(duration_input, constraint, constraint_value) { + return { + ...StsError.ValidationError, + message: `Value ${duration_input} for durationSeconds failed to satisfy constraint: Member must have value ${constraint} than or equal to ${constraint_value}`, + }; +} + +exports.generate_session_token = generate_session_token; +exports.parse_sts_duration = parse_sts_duration; diff --git a/src/sdk/sts_sdk.js b/src/sdk/sts_sdk.js index 80decc4f89..754ea3a11a 100644 --- a/src/sdk/sts_sdk.js +++ b/src/sdk/sts_sdk.js @@ -7,6 +7,8 @@ const { RpcError } = require('../rpc'); const signature_utils = require('../util/signature_utils'); const { account_cache } = require('./object_sdk'); const BucketSpaceNB = require('./bucketspace_nb'); +const jwt = require('jsonwebtoken'); +const ldap_client = require('../util/ldap_client'); class StsSDK { @@ -27,7 +29,7 @@ class StsSDK { return this.auth_token; } - /** + /** * @returns {nb.BucketSpace} */ _get_bucketspace() { @@ -51,16 +53,14 @@ class StsSDK { } } - async get_assumed_role(req) { - dbg.log1('sts_sdk.get_assumed_role body', req.body); - // arn:aws:sts::access_key:role/role_name - const role_name_idx = req.body.role_arn.lastIndexOf('/') + 1; - const role_name = req.body.role_arn.slice(role_name_idx); - const access_key = req.body.role_arn.split(':')[4]; + async _assume_role(role_arn) { + const role_name_idx = role_arn.lastIndexOf('/') + 1; + const role_name = role_arn.slice(role_name_idx); + const access_key = role_arn.split(':')[4]; const account = await account_cache.get_with_cache({ bucketspace: this._get_bucketspace(), - access_key: access_key, + access_key, }); if (!account) { throw new RpcError('NO_SUCH_ACCOUNT', 'No such account with access_key: ' + access_key); @@ -71,12 +71,69 @@ class StsSDK { dbg.log0('sts_sdk.get_assumed_role res', account, 'account.role_config: ', account.role_config); + return account; + } + + async get_assumed_role(req) { + dbg.log1('sts_sdk.get_assumed_role body', req.body); + // arn:aws:sts::access_key:role/role_name + const account = await this._assume_role(req.body.role_arn); + return { - access_key, + access_key: req.body.role_arn.split(':')[4], role_config: account.role_config }; } + async get_assumed_ldap_user(req) { + dbg.log1('sts_sdk.get_assumed_ldap_user body', req.body); + let web_token; + const jwt_secret = ldap_client.instance().ldap_params?.jwt_secret; + if (jwt_secret) { + try { + web_token = jwt.verify(req.body.web_identity_token, jwt_secret); + } catch (err) { + dbg.error('get_assumed_ldap_user error: JWT token verification failed', err); + if (err.message.includes('TokenExpiredError')) { + throw new RpcError('EXPIRED_WEB_IDENTITY_TOKEN', err.message); + } else { + throw new RpcError('INVALID_WEB_IDENTITY_TOKEN', err.message); + } + } + } else { + dbg.warn('get_assumed_ldap_user: No LDAP JWT secret found, failing back to decoding'); + web_token = jwt.decode(req.body.web_identity_token); + } + if (!web_token.user) { + throw new RpcError('INVALID_WEB_IDENTITY_TOKEN', 'Missing a required claim: user'); + } + if (!web_token.password) { + throw new RpcError('INVALID_WEB_IDENTITY_TOKEN', 'Missing a required claim: password'); + } + + // TODO: we should see if we can move to the authentication phase + const ldap_user = web_token.user; + const ldap_password = web_token.password; + if (!(await ldap_client.is_ldap_configured()) || !ldap_client.instance().is_connected()) { + throw new RpcError('ACCESS_DENIED', 'LDAP is not configured or not connected'); + } + let dn; + try { + dn = await ldap_client.instance().authenticate(ldap_user, ldap_password); + } catch (err) { + dbg.error('get_assumed_ldap_user error:', err); + throw new RpcError('ACCESS_DENIED', 'issue with LDAP authentication'); + } + const account = await this._assume_role(req.body.role_arn); + dbg.log0('sts_sdk.get_assumed_role_with_web_identity res', account, + 'account.role_config: ', account.role_config); + return { + access_key: req.body.role_arn.split(':')[4], + role_config: account.role_config, + dn, + }; + } + generate_temp_access_keys() { return cloud_utils.generate_access_keys(); } @@ -88,7 +145,11 @@ class StsSDK { signature_utils.authorize_request_account_by_token(token, this.requesting_account); return; } - throw new RpcError('UNAUTHORIZED', `No permission to access bucket`); + // assume role with web identity is Anonymous + if (req.op_name === 'post_assume_role_with_web_identity') { + return; + } + throw new RpcError('UNAUTHORIZED', `No permission to sts ops`); } } diff --git a/src/test/integration_tests/api/sts/test_sts.js b/src/test/integration_tests/api/sts/test_sts.js index 59b35b1a67..dfddde0788 100644 --- a/src/test/integration_tests/api/sts/test_sts.js +++ b/src/test/integration_tests/api/sts/test_sts.js @@ -8,12 +8,14 @@ const AWS = require('aws-sdk'); const https = require('https'); const mocha = require('mocha'); const assert = require('assert'); +const jwt = require('jsonwebtoken'); const stsErr = require('../../../../endpoint/sts/sts_errors').StsError; const http_utils = require('../../../../util/http_utils'); const dbg = require('../../../../util/debug_module')(__filename); const cloud_utils = require('../../../../util/cloud_utils'); const jwt_utils = require('../../../../util/jwt_utils'); const config = require('../../../../../config'); +const ldap_client = require('../../../../util/ldap_client'); const { S3Error } = require('../../../../endpoint/s3/s3_errors'); const defualt_expiry_seconds = Math.ceil(config.STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS / 1000); @@ -119,7 +121,7 @@ mocha.describe('STS tests', function() { Version: '2012-10-17', Statement: [{ Effect: 'Allow', - Principal: {AWS: [user_a, user_b, user_c]}, + Principal: { AWS: [user_a, user_b, user_c] }, Action: ['s3:*'], Resource: ['arn:aws:s3:::first.bucket/*', 'arn:aws:s3:::first.bucket'], }] @@ -888,3 +890,64 @@ mocha.describe('Assume role policy tests', function() { }), errors.malformed_policy.rpc_code, errors.malformed_policy.message_action); }); }); + +mocha.describe('Assume role with web indentity tests', function() { + const user_a = 'alice1'; + + /** @type {AWS.STS} */ + let anon_sts; + mocha.before(async function() { + const self = this; // eslint-disable-line no-invalid-this + self.timeout(60000); + + // const random_access_keys = cloud_utils.generate_access_keys(); + anon_sts = new AWS.STS({ + endpoint: coretest.get_https_address_sts(), + region: 'us-east-1', + sslEnabled: true, + computeChecksums: true, + httpOptions: { agent: new https.Agent({ keepAlive: false, rejectUnauthorized: false }) }, + s3ForcePathStyle: true, + signatureVersion: 'v4', + s3DisableBodySigning: false, + }); + ldap_client.instance().ldap_params = { + jwt_secret: "TEST_SECRET" + }; + }); + + mocha.it('anonymous user a with bad jwt - should be rejected', async function() { + await assert_throws_async(anon_sts.assumeRoleWithWebIdentity({ + RoleArn: `arn:aws:sts::ldap:role/${user_a}`, + RoleSessionName: 'just_a_dummy_session_name', + WebIdentityToken: 'just_a_dummy_wit' + }).promise(), stsErr.InvalidIdentityToken.code, "jwt malformed"); + }); + + mocha.it('anonymous user a with invalid signature - should be rejected', async function() { + const bad_signed_wit = jwt.sign({ user: user_a, password: 'dummy_password' }, 'invalid signature'); + await assert_throws_async(anon_sts.assumeRoleWithWebIdentity({ + RoleArn: `arn:aws:sts::ldap:role/${user_a}`, + RoleSessionName: 'just_a_dummy_session_name', + WebIdentityToken: bad_signed_wit + }).promise(), stsErr.InvalidIdentityToken.code, "invalid signature"); + }); + + mocha.it('anonymous user a with missing password - should be rejected', async function() { + const missing_pwd_wit = jwt.sign({ user: user_a }, ldap_client.instance().ldap_params.jwt_secret); + await assert_throws_async(anon_sts.assumeRoleWithWebIdentity({ + RoleArn: `arn:aws:sts::ldap:role/${user_a}`, + RoleSessionName: 'just_a_dummy_session_name', + WebIdentityToken: missing_pwd_wit + }).promise(), stsErr.InvalidIdentityToken.code, "Missing a required claim: password"); + }); + + mocha.it('anonymous user a with missing user name - should be rejected', async function() { + const missing_usr_wit = jwt.sign({ password: 'password' }, ldap_client.instance().ldap_params.jwt_secret); + await assert_throws_async(anon_sts.assumeRoleWithWebIdentity({ + RoleArn: `arn:aws:sts::ldap:role/${user_a}`, + RoleSessionName: 'just_a_dummy_session_name', + WebIdentityToken: missing_usr_wit + }).promise(), stsErr.InvalidIdentityToken.code, "Missing a required claim: user"); + }); +}); diff --git a/src/util/ldap_client.js b/src/util/ldap_client.js new file mode 100644 index 0000000000..4ad51accdc --- /dev/null +++ b/src/util/ldap_client.js @@ -0,0 +1,155 @@ +/* Copyright (C) 2016 NooBaa */ +'use strict'; + +require('../util/fips'); + +const fs = require('fs'); +const config = require('../../config'); +const EventEmitter = require('events').EventEmitter; +const ldap = require('ldapts'); +const dbg = require('./debug_module')(__filename); +const P = require('../util/promise'); + +class LdapClient extends EventEmitter { + async disconnect() { + dbg.log0('ldap client disconnect called'); + this._disconnected_state = true; + this._connect_promise = null; + if (this.admin_client) { + try { + await this.admin_client.unbind(); + } catch (err) { + // ignore + } + } + } + + async reconnect() { + dbg.log0(`reconnect called`); + await this.disconnect(); + return this.connect(); + } + + constructor() { + super(); + this.load_ldap_config(); + fs.watchFile(config.LDAP_CONFIG_PATH, { + interval: config.NC_RELOAD_CONFIG_INTERVAL + }, () => this.load_ldap_config()).unref(); + } + + async load_ldap_config() { + try { + dbg.log0('load_ldap_config called'); + const params = JSON.parse(fs.readFileSync(config.LDAP_CONFIG_PATH).toString()); + this.ldap_params = { + uri: params.uri || 'ldaps://127.0.0.1:636', + admin: params.admin_user || 'Administrator', + secret: params.admin_password || 'Passw0rd', + search_dn: params.search_dn || 'ou=people,dc=example,dc=com', + dn_attribute: params.dn_attribute || 'uid', // for LDAP 'sAMAccountName' for AD + search_scope: params.search_scope || 'sub', + jwt_secret: params.jwt_secret, + ...params, + }; + this.tls_options = this.ldap_params.tls_options || { + 'rejectUnauthorized': false, + }; + this.admin_client = new ldap.Client({ + url: this.ldap_params.uri, + tlsOptions: this.tls_options, + }); + if (this.is_connected) { + await this.reconnect(); + } + } catch (err) { + // we cannot rethrow, next watch event will try to load again + } + } + + /** + * @returns {LdapClient} + */ + static instance() { + if (!LdapClient._instance) LdapClient._instance = new LdapClient(); + return LdapClient._instance; + } + + is_connected() { + return this.admin_client?.isConnected; + } + + async connect() { + this._disconnected_state = false; + if (this._connect_promise) return this._connect_promise; + dbg.log0('connect called, current url:', this.ldap_params.uri); + this._connect_promise = this._connect(); + return this._connect_promise; + } + + async _connect() { + let is_connected = false; + while (!is_connected) { + try { + await this._bind(this.admin_client, this.ldap_params.admin, this.ldap_params.secret); + dbg.log0('_connect: initial connect succeeded'); + is_connected = true; + } catch (err) { + dbg.error('_connect: initial connect failed, will retry', err.message); + await P.delay(3000); + } + } + } + + async _bind(client, user, password) { + try { + await client.bind(user, password); + } catch (err) { + await client.unbind(); + throw err; + } + } + + async authenticate(user, password) { + // EqualityFilter automatically escapes the value to prevent LDAP injection + const eqFilter = new ldap.EqualityFilter({ + attribute: this.ldap_params.dn_attribute, + value: user, + }); + /** @type {ldap.SearchOptions} */ + const search_options = { + filter: eqFilter, + scope: this.ldap_params.search_scope, + attributes: ['dn'] + }; + const user_client = new ldap.Client({ + url: this.ldap_params.uri, + tlsOptions: this.tls_options, + }); + const { searchEntries } = await this.admin_client.search(this.ldap_params.search_dn, search_options); + if (!searchEntries || searchEntries.length === 0) { + throw new Error('User not found'); + } + await this._bind(user_client, searchEntries[0].dn, password); + return searchEntries[0].dn; + } +} + +async function is_ldap_configured() { + try { + return fs.statSync(config.LDAP_CONFIG_PATH).isFile(); + } catch (err) { + if (err.code === 'ENOENT') { + return false; + } + throw err; + } +} + + +LdapClient._instance = undefined; + +// EXPORTS +exports.LdapClient = LdapClient; +exports.instance = LdapClient.instance; +exports.is_ldap_configured = is_ldap_configured;