diff --git a/config.js b/config.js index 0941f58f94..77856b4610 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 // ////////////////// @@ -1198,7 +1203,7 @@ function _get_config_root() { /** * go over the config object and set the relevant configurations as environment variables -*/ + */ function _set_nc_config_to_env() { const config_to_env = ['NOOBAA_LOG_LEVEL', 'UV_THREADPOOL_SIZE', 'GPFS_DL_PATH', 'NSFS_ENABLE_DYNAMIC_SUPPLEMENTAL_GROUPS']; for (const configuration_key of config_to_env) { diff --git a/package-lock.json b/package-lock.json index 40e5b241ce..499578a5b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,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", @@ -3906,6 +3907,15 @@ "node": ">= 10" } }, + "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", @@ -4522,6 +4532,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", @@ -8780,6 +8799,102 @@ "node": ">=6" } }, + "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", @@ -12015,6 +12130,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 c3674929e1..9381b84a13 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,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 7ed3e9efa7..f4722cd06c 100755 --- a/src/endpoint/endpoint.js +++ b/src/endpoint/endpoint.js @@ -43,9 +43,11 @@ 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 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) { @@ -116,16 +118,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'; @@ -198,8 +200,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 }); @@ -227,6 +235,10 @@ async function main(options = {}) { object_io: object_io, })); } + + if (await ldap_client.is_ldap_configured()) { + await 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..5236997469 --- /dev/null +++ b/src/endpoint/sts/ops/sts_post_assume_role_with_web_identity.js @@ -0,0 +1,72 @@ +/* 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'); + +/** + * 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: ', req.body); + 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..474850fca6 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', + sts_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..e51242a8a7 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() { @@ -77,6 +79,51 @@ class StsSDK { }; } + async get_assumed_ldap_user(req) { + dbg.log1('sts_sdk.get_assumed_ldap_user body', req.body); + const jwt_secret = ldap_client.is_ldap_configured && ldap_client.instance().ldap_params.jwt_secret; + if (!jwt_secret) { + dbg.error('get_assumed_ldap_user error: No LDAP JWT secret found'); + throw new RpcError('INTERNAL_ERROR', 'No LDAP JWT secret found'); + } + const access_key = req.body.role_arn.split(':')[4]; + let web_token; + 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); + } + } + 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'); + } + + const ldap_user = web_token.user; + const ldap_password = web_token.password; + + try { + const dn = await ldap_client.authenticate(ldap_user, ldap_password); + // TODO: Add ldap user to DB + return { + access_key, + dn, + role_config: { + role_name: req.body.role_arn.split('/')[1], + } + }; + } catch (err) { + dbg.error('get_assumed_ldap_user error:', err); + throw new RpcError('ACCESS_DENIED', 'issue with LDAP authentication'); + } + } + generate_temp_access_keys() { return cloud_utils.generate_access_keys(); } @@ -88,7 +135,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/unit_tests/test_sts.js b/src/test/unit_tests/test_sts.js index 4823272be1..9c9b699773 100644 --- a/src/test/unit_tests/test_sts.js +++ b/src/test/unit_tests/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,63 @@ 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, + }); + }); + + 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 jwt_secret = ldap_client.instance().ldap_params.jwt_secret; + const missing_pwd_wit = jwt.sign({ user: user_a }, 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 jwt_secret = ldap_client.instance().ldap_params.jwt_secret; + const missing_usr_wit = jwt.sign({ password: 'password' }, 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..3bff2a9a3f --- /dev/null +++ b/src/util/ldap_client.js @@ -0,0 +1,145 @@ +/* Copyright (C) 2016 NooBaa */ +'use strict'; + +require('../util/fips'); + +const fs = require('fs'); +const config = require('../../config'); +const EventEmitter = require('events').EventEmitter; +const ldap_client = 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; + this.admin_client.unbind(); + } + + async reconnect() { + dbg.log0(`reconnect called`); + 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 || 'ldap://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_filter: params.search_filter || '(uid=%s)', + 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.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); + 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) { + /** @type {ldap_client.SearchOptions}*/ + const search_options = { + filter: `(${this.ldap_params.dn_attribute}=${user})`, + scope: this.ldap_params.search_scope, + attributes: ['dn'] + }; + const user_client = new ldap_client.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;