diff --git a/README.md b/README.md index b67ba11..49583e7 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,19 @@ PRIVATE_KEY=privkey.pem (required) CERT_CHAIN=certificate.pem (required) CA_ROOT=fullchain.pem (optional) -# ENV variables required for MODE: `server` +## ENV variables required for MODE: `server` DB_CONNECT=mongodb+srv://:@/?retryWrites=true&w=majority +# AUTH_PROVIDERS options: [ldap|internal] default: `internal` +AUTH_PROVIDERS= + +## ENV variables required for AUTH_MECHANISM: `ldap` +LDAP_URL= +LDAP_BIND_DN= +LDAP_BIND_PASSWORD = +LDAP_USERS_BASE_DN = +LDAP_GROUPS_BASE_DN = + # options: [disable|enable] default: `disable` for `server` & `enable` for `desktop` # If enabled, be sure to also configure the WHITELIST of third party servers. CORS= diff --git a/api/.env.example b/api/.env.example index 7d509f3..e2fafb7 100644 --- a/api/.env.example +++ b/api/.env.example @@ -14,6 +14,14 @@ HELMET_COEP=[true|false] if omitted HELMET default will be used DB_CONNECT=mongodb+srv://:@/?retryWrites=true&w=majority +AUTH_PROVIDERS=[ldap|internal] default considered as internal + +LDAP_URL= +LDAP_BIND_DN= +LDAP_BIND_PASSWORD = +LDAP_USERS_BASE_DN = +LDAP_GROUPS_BASE_DN = + RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node diff --git a/api/package-lock.json b/api/package-lock.json index 592a288..d1f2f6c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -20,6 +20,7 @@ "helmet": "^5.0.2", "joi": "^17.4.2", "jsonwebtoken": "^8.5.1", + "ldapjs": "2.3.3", "mongoose": "^6.0.12", "mongoose-sequence": "^5.3.1", "morgan": "^1.10.0", @@ -42,6 +43,7 @@ "@types/express-session": "^1.17.4", "@types/jest": "^26.0.24", "@types/jsonwebtoken": "^8.5.5", + "@types/ldapjs": "^2.2.4", "@types/mongoose-sequence": "^3.0.6", "@types/morgan": "^1.9.3", "@types/multer": "^1.4.7", @@ -2044,6 +2046,15 @@ "@types/node": "*" } }, + "node_modules/@types/ldapjs": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.4.tgz", + "integrity": "sha512-+ZMVolW4N1zpnQ4SgH8nfpIFuiDOfbnXSbwQoBiLaq8mF0vo8FOKotQzKkfoWxbV0lWU1d4V+keZZ07klyOSng==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -2219,6 +2230,11 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, "node_modules/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -2474,6 +2490,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -2485,6 +2509,14 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -2646,6 +2678,17 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4049,6 +4092,14 @@ } ] }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ] + }, "node_modules/fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -6761,6 +6812,35 @@ "node": ">8" } }, + "node_modules/ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "dependencies": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8050,6 +8130,14 @@ "node": ">=6" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -9646,6 +9734,43 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -11547,6 +11672,15 @@ "@types/node": "*" } }, + "@types/ldapjs": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.4.tgz", + "integrity": "sha512-+ZMVolW4N1zpnQ4SgH8nfpIFuiDOfbnXSbwQoBiLaq8mF0vo8FOKotQzKkfoWxbV0lWU1d4V+keZZ07klyOSng==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -11721,6 +11855,11 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -11919,6 +12058,14 @@ "is-string": "^1.0.7" } }, + "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==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, "asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -11930,6 +12077,11 @@ "safer-buffer": "^2.1.0" } }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + }, "async": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", @@ -12057,6 +12209,14 @@ "babel-preset-current-node-syntax": "^1.0.0" } }, + "backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "requires": { + "precond": "0.2" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -13136,6 +13296,11 @@ } } }, + "extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==" + }, "fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -15176,6 +15341,29 @@ "asn1.js": "^5.4.1" } }, + "ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "requires": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + } + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -16147,6 +16335,11 @@ "tunnel-agent": "^0.6.0" } }, + "precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -17340,6 +17533,36 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "requires": { + "verror": "1.10.0" + }, + "dependencies": { + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + } + } + }, + "verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/api/package.json b/api/package.json index ea5b9ff..105e91c 100644 --- a/api/package.json +++ b/api/package.json @@ -59,6 +59,7 @@ "helmet": "^5.0.2", "joi": "^17.4.2", "jsonwebtoken": "^8.5.1", + "ldapjs": "2.3.3", "mongoose": "^6.0.12", "mongoose-sequence": "^5.3.1", "morgan": "^1.10.0", @@ -78,6 +79,7 @@ "@types/express-session": "^1.17.4", "@types/jest": "^26.0.24", "@types/jsonwebtoken": "^8.5.5", + "@types/ldapjs": "^2.2.4", "@types/mongoose-sequence": "^3.0.6", "@types/morgan": "^1.9.3", "@types/multer": "^1.4.7", diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index 4275971..f7f18d4 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -318,6 +318,11 @@ components: - isAdmin type: object additionalProperties: false + AuthProviderType: + enum: + - ldap + - internal + type: string UserPayload: properties: displayName: @@ -331,6 +336,10 @@ components: password: type: string description: 'Password for user' + authProvider: + $ref: '#/components/schemas/AuthProviderType' + description: 'Identifies the source from which user is created' + example: internal isAdmin: type: boolean description: 'Account should be admin or not, defaults to false' @@ -382,6 +391,10 @@ components: type: string description: 'Description of the group' example: 'This group represents Data Controller Users' + authProvider: + $ref: '#/components/schemas/AuthProviderType' + description: 'Identifies the source from which group is created' + example: 'false' isActive: type: boolean description: 'Group should be active or not, defaults to true' @@ -622,6 +635,51 @@ paths: - bearerAuth: [] parameters: [] + /SASjsApi/authConfig: + get: + operationId: GetDetail + responses: + '200': + description: Ok + content: + application/json: + schema: {} + examples: + 'Example 1': + value: {ldap: {LDAP_URL: 'ldaps://my.ldap.server:636', LDAP_BIND_DN: 'cn=admin,ou=system,dc=cloudron', LDAP_BIND_PASSWORD: secret, LDAP_USERS_BASE_DN: 'ou=users,dc=cloudron', LDAP_GROUPS_BASE_DN: 'ou=groups,dc=cloudron'}} + summary: 'Gives the detail of Auth Mechanism.' + tags: + - Auth_Config + security: + - + bearerAuth: [] + parameters: [] + /SASjsApi/authConfig/synchronizeWithLDAP: + post: + operationId: SynchronizeWithLDAP + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + groupCount: {type: number, format: double} + userCount: {type: number, format: double} + required: + - groupCount + - userCount + type: object + examples: + 'Example 1': + value: {users: 5, groups: 3} + summary: 'Synchronizes LDAP users and groups with internal DB and returns the count of imported users and groups.' + tags: + - Auth_Config + security: + - + bearerAuth: [] + parameters: [] /SASjsApi/client: post: operationId: CreateClient @@ -1794,6 +1852,9 @@ tags: - name: Auth description: 'Operations about auth' + - + name: Auth_Config + description: 'Operations about external auth providers' - name: Client description: 'Operations about clients' diff --git a/api/src/controllers/authConfig.ts b/api/src/controllers/authConfig.ts new file mode 100644 index 0000000..2d2f4bb --- /dev/null +++ b/api/src/controllers/authConfig.ts @@ -0,0 +1,185 @@ +import express from 'express' +import { Security, Route, Tags, Get, Post, Example } from 'tsoa' + +import { LDAPClient, LDAPUser, LDAPGroup, AuthProviderType } from '../utils' +import { randomBytes } from 'crypto' +import User from '../model/User' +import Group from '../model/Group' +import Permission from '../model/Permission' + +@Security('bearerAuth') +@Route('SASjsApi/authConfig') +@Tags('Auth_Config') +export class AuthConfigController { + /** + * @summary Gives the detail of Auth Mechanism. + * + */ + @Example({ + ldap: { + LDAP_URL: 'ldaps://my.ldap.server:636', + LDAP_BIND_DN: 'cn=admin,ou=system,dc=cloudron', + LDAP_BIND_PASSWORD: 'secret', + LDAP_USERS_BASE_DN: 'ou=users,dc=cloudron', + LDAP_GROUPS_BASE_DN: 'ou=groups,dc=cloudron' + } + }) + @Get('/') + public getDetail() { + return getAuthConfigDetail() + } + + /** + * @summary Synchronizes LDAP users and groups with internal DB and returns the count of imported users and groups. + * + */ + @Example({ + users: 5, + groups: 3 + }) + @Post('/synchronizeWithLDAP') + public async synchronizeWithLDAP() { + return synchronizeWithLDAP() + } +} + +const synchronizeWithLDAP = async () => { + process.logger.info('Syncing LDAP with internal DB') + + const permissions = await Permission.get({}) + await Permission.deleteMany() + await User.deleteMany({ authProvider: AuthProviderType.LDAP }) + await Group.deleteMany({ authProvider: AuthProviderType.LDAP }) + + const ldapClient = await LDAPClient.init() + + process.logger.info('fetching LDAP users') + const users = await ldapClient.getAllLDAPUsers() + + process.logger.info('inserting LDAP users to DB') + + const existingUsers: string[] = [] + const importedUsers: LDAPUser[] = [] + + for (const user of users) { + const usernameExists = await User.findOne({ username: user.username }) + if (usernameExists) { + existingUsers.push(user.username) + continue + } + + const hashPassword = User.hashPassword(randomBytes(64).toString('hex')) + + await User.create({ + displayName: user.displayName, + username: user.username, + password: hashPassword, + authProvider: AuthProviderType.LDAP + }) + + importedUsers.push(user) + } + + if (existingUsers.length > 0) { + process.logger.info( + 'Failed to insert following users as they already exist in DB:' + ) + existingUsers.forEach((user) => process.logger.log(`* ${user}`)) + } + + process.logger.info('fetching LDAP groups') + const groups = await ldapClient.getAllLDAPGroups() + + process.logger.info('inserting LDAP groups to DB') + + const existingGroups: string[] = [] + const importedGroups: LDAPGroup[] = [] + + for (const group of groups) { + const groupExists = await Group.findOne({ name: group.name }) + if (groupExists) { + existingGroups.push(group.name) + continue + } + + await Group.create({ + name: group.name, + authProvider: AuthProviderType.LDAP + }) + + importedGroups.push(group) + } + + if (existingGroups.length > 0) { + process.logger.info( + 'Failed to insert following groups as they already exist in DB:' + ) + existingGroups.forEach((group) => process.logger.log(`* ${group}`)) + } + + process.logger.info('associating users and groups') + + for (const group of importedGroups) { + const dbGroup = await Group.findOne({ name: group.name }) + if (dbGroup) { + for (const member of group.members) { + const user = importedUsers.find((user) => user.uid === member) + if (user) { + const dbUser = await User.findOne({ username: user.username }) + if (dbUser) await dbGroup.addUser(dbUser) + } + } + } + } + + process.logger.info('setting permissions') + + for (const permission of permissions) { + const newPermission = new Permission({ + path: permission.path, + type: permission.type, + setting: permission.setting + }) + + if (permission.user) { + const dbUser = await User.findOne({ username: permission.user.username }) + if (dbUser) newPermission.user = dbUser._id + } else if (permission.group) { + const dbGroup = await Group.findOne({ name: permission.group.name }) + if (dbGroup) newPermission.group = dbGroup._id + } + await newPermission.save() + } + + process.logger.info('LDAP synchronization completed!') + + return { + userCount: importedUsers.length, + groupCount: importedGroups.length + } +} + +const getAuthConfigDetail = () => { + const { AUTH_PROVIDERS } = process.env + + const returnObj: any = {} + + if (AUTH_PROVIDERS === AuthProviderType.LDAP) { + const { + LDAP_URL, + LDAP_BIND_DN, + LDAP_BIND_PASSWORD, + LDAP_USERS_BASE_DN, + LDAP_GROUPS_BASE_DN + } = process.env + + returnObj.ldap = { + LDAP_URL: LDAP_URL ?? '', + LDAP_BIND_DN: LDAP_BIND_DN ?? '', + LDAP_BIND_PASSWORD: LDAP_BIND_PASSWORD ?? '', + LDAP_USERS_BASE_DN: LDAP_USERS_BASE_DN ?? '', + LDAP_GROUPS_BASE_DN: LDAP_GROUPS_BASE_DN ?? '' + } + } + return returnObj +} diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index ff88ac6..4d3433c 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -1,4 +1,5 @@ export * from './auth' +export * from './authConfig' export * from './client' export * from './code' export * from './drive' diff --git a/api/src/controllers/web.ts b/api/src/controllers/web.ts index ada7550..b3b7d08 100644 --- a/api/src/controllers/web.ts +++ b/api/src/controllers/web.ts @@ -5,7 +5,12 @@ import { readFile } from '@sasjs/utils' import User from '../model/User' import Client from '../model/Client' -import { getWebBuildFolder, generateAuthCode } from '../utils' +import { + getWebBuildFolder, + generateAuthCode, + AuthProviderType, + LDAPClient +} from '../utils' import { InfoJWT } from '../types' import { AuthController } from './auth' @@ -80,8 +85,16 @@ const login = async ( const user = await User.findOne({ username }) if (!user) throw new Error('Username is not found.') - const validPass = user.comparePassword(password) - if (!validPass) throw new Error('Invalid password.') + if ( + process.env.AUTH_MECHANISM === AuthProviderType.LDAP && + user.authProvider === AuthProviderType.LDAP + ) { + const ldapClient = await LDAPClient.init() + await ldapClient.verifyUser(username, password) + } else { + const validPass = user.comparePassword(password) + if (!validPass) throw new Error('Invalid password.') + } req.session.loggedIn = true req.session.user = { diff --git a/api/src/middlewares/desktop.ts b/api/src/middlewares/desktop.ts index b2935fd..4db2396 100644 --- a/api/src/middlewares/desktop.ts +++ b/api/src/middlewares/desktop.ts @@ -1,7 +1,7 @@ import { RequestHandler, Request } from 'express' import { userInfo } from 'os' import { RequestUser } from '../types' -import { ModeType } from '../utils' +import { ModeType, AuthProviderType } from '../utils' const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1 @@ -27,6 +27,18 @@ export const desktopRestrict: RequestHandler = (req, res, next) => { next() } +export const ldapRestrict: RequestHandler = (req, res, next) => { + const { AUTH_MECHANISM } = process.env + + if (AUTH_MECHANISM === AuthProviderType.LDAP) { + return res + .status(403) + .send(`Not Allowed while AUTH_MECHANISM is '${AuthProviderType.LDAP}'.`) + } + + next() +} + export const desktopUser: RequestUser = { userId: 12345, clientId: 'desktop_app', diff --git a/api/src/model/Group.ts b/api/src/model/Group.ts index 3185f44..0a0c8ef 100644 --- a/api/src/model/Group.ts +++ b/api/src/model/Group.ts @@ -1,6 +1,7 @@ import mongoose, { Schema, model, Document, Model } from 'mongoose' import { GroupDetailsResponse } from '../controllers' import User, { IUser } from './User' +import { AuthProviderType } from '../utils' const AutoIncrement = require('mongoose-sequence')(mongoose) export const PUBLIC_GROUP_NAME = 'Public' @@ -16,6 +17,11 @@ export interface GroupPayload { * @example "This group represents Data Controller Users" */ description: string + /** + * Identifies the source from which group is created + * @example "false" + */ + authProvider?: AuthProviderType /** * Group should be active or not, defaults to true * @example "true" @@ -46,6 +52,11 @@ const groupSchema = new Schema({ type: String, default: 'Group description.' }, + authProvider: { + type: String, + enum: AuthProviderType, + default: 'internal' + }, isActive: { type: Boolean, default: true diff --git a/api/src/model/User.ts b/api/src/model/User.ts index dd0123b..86b626b 100644 --- a/api/src/model/User.ts +++ b/api/src/model/User.ts @@ -1,6 +1,7 @@ import mongoose, { Schema, model, Document, Model } from 'mongoose' const AutoIncrement = require('mongoose-sequence')(mongoose) import bcrypt from 'bcryptjs' +import { AuthProviderType } from '../utils' export interface UserPayload { /** @@ -17,6 +18,11 @@ export interface UserPayload { * Password for user */ password: string + /** + * Identifies the source from which user is created + * @example "internal" + */ + authProvider?: AuthProviderType /** * Account should be admin or not, defaults to false * @example "false" @@ -67,6 +73,11 @@ const userSchema = new Schema({ type: String, required: true }, + authProvider: { + type: String, + enum: AuthProviderType, + default: 'internal' + }, isAdmin: { type: Boolean, default: false diff --git a/api/src/routes/api/authConfig.ts b/api/src/routes/api/authConfig.ts new file mode 100644 index 0000000..583f577 --- /dev/null +++ b/api/src/routes/api/authConfig.ts @@ -0,0 +1,25 @@ +import express from 'express' +import { AuthConfigController } from '../../controllers' +const authConfigRouter = express.Router() + +authConfigRouter.get('/', async (req, res) => { + const controller = new AuthConfigController() + try { + const response = controller.getDetail() + res.send(response) + } catch (err: any) { + res.status(500).send(err.toString()) + } +}) + +authConfigRouter.post('/synchronizeWithLDAP', async (req, res) => { + const controller = new AuthConfigController() + try { + const response = await controller.synchronizeWithLDAP() + res.send(response) + } catch (err: any) { + res.status(500).send(err.toString()) + } +}) + +export default authConfigRouter diff --git a/api/src/routes/api/group.ts b/api/src/routes/api/group.ts index 930b817..7e83dff 100644 --- a/api/src/routes/api/group.ts +++ b/api/src/routes/api/group.ts @@ -1,12 +1,17 @@ import express from 'express' import { GroupController } from '../../controllers/' -import { authenticateAccessToken, verifyAdmin } from '../../middlewares' +import { + ldapRestrict, + authenticateAccessToken, + verifyAdmin +} from '../../middlewares' import { getGroupValidation, registerGroupValidation } from '../../utils' const groupRouter = express.Router() groupRouter.post( '/', + ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -82,6 +87,7 @@ groupRouter.get( groupRouter.post( '/:groupId/:userId', + ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -106,6 +112,7 @@ groupRouter.post( groupRouter.delete( '/:groupId/:userId', + ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -130,6 +137,7 @@ groupRouter.delete( groupRouter.delete( '/:groupId', + ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { diff --git a/api/src/routes/api/index.ts b/api/src/routes/api/index.ts index 04e4a19..4c4acd0 100644 --- a/api/src/routes/api/index.ts +++ b/api/src/routes/api/index.ts @@ -18,6 +18,7 @@ import clientRouter from './client' import authRouter from './auth' import sessionRouter from './session' import permissionRouter from './permission' +import authConfigRouter from './authConfig' const router = express.Router() @@ -43,6 +44,14 @@ router.use( permissionRouter ) +router.use( + '/authConfig', + desktopRestrict, + authenticateAccessToken, + verifyAdmin, + authConfigRouter +) + router.use( '/', swaggerUi.serve, diff --git a/api/src/routes/api/user.ts b/api/src/routes/api/user.ts index 20ce88c..2baa54f 100644 --- a/api/src/routes/api/user.ts +++ b/api/src/routes/api/user.ts @@ -3,7 +3,8 @@ import { UserController } from '../../controllers/' import { authenticateAccessToken, verifyAdmin, - verifyAdminIfNeeded + verifyAdminIfNeeded, + ldapRestrict } from '../../middlewares' import { deleteUserValidation, @@ -14,18 +15,24 @@ import { const userRouter = express.Router() -userRouter.post('/', authenticateAccessToken, verifyAdmin, async (req, res) => { - const { error, value: body } = registerUserValidation(req.body) - if (error) return res.status(400).send(error.details[0].message) +userRouter.post( + '/', + ldapRestrict, + authenticateAccessToken, + verifyAdmin, + async (req, res) => { + const { error, value: body } = registerUserValidation(req.body) + if (error) return res.status(400).send(error.details[0].message) - const controller = new UserController() - try { - const response = await controller.createUser(body) - res.send(response) - } catch (err: any) { - res.status(403).send(err.toString()) + const controller = new UserController() + try { + const response = await controller.createUser(body) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } } -}) +) userRouter.get('/', authenticateAccessToken, async (req, res) => { const controller = new UserController() @@ -70,6 +77,7 @@ userRouter.get('/:userId', authenticateAccessToken, async (req, res) => { userRouter.patch( '/by/username/:username', + ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -98,6 +106,7 @@ userRouter.patch( userRouter.patch( '/:userId', + ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -120,6 +129,7 @@ userRouter.patch( userRouter.delete( '/by/username/:username', + ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -148,6 +158,7 @@ userRouter.delete( userRouter.delete( '/:userId', + ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index 7baf1e2..f13da3d 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -18,6 +18,7 @@ export * from './getTokensFromDB' export * from './instantiateLogger' export * from './isDebugOn' export * from './isPublicRoute' +export * from './ldapClient' export * from './zipped' export * from './parseLogToArray' export * from './removeTokensInDB' diff --git a/api/src/utils/ldapClient.ts b/api/src/utils/ldapClient.ts new file mode 100644 index 0000000..828b6ed --- /dev/null +++ b/api/src/utils/ldapClient.ts @@ -0,0 +1,163 @@ +import { createClient, Client } from 'ldapjs' +import { ReturnCode } from './verifyEnvVariables' + +export interface LDAPUser { + uid: string + username: string + displayName: string +} + +export interface LDAPGroup { + name: string + members: string[] +} + +export class LDAPClient { + private ldapClient: Client + private static classInstance: LDAPClient | null + + private constructor() { + process.logger.info('creating LDAP client') + this.ldapClient = createClient({ url: process.env.LDAP_URL as string }) + + this.ldapClient.on('error', (error) => { + process.logger.error(error.message) + }) + } + + static async init() { + if (!LDAPClient.classInstance) { + LDAPClient.classInstance = new LDAPClient() + + process.logger.info('binding LDAP client') + await LDAPClient.classInstance.bind().catch((error) => { + LDAPClient.classInstance = null + throw error + }) + } + return LDAPClient.classInstance + } + + private async bind() { + const promise = new Promise((resolve, reject) => { + const { LDAP_BIND_DN, LDAP_BIND_PASSWORD } = process.env + this.ldapClient.bind(LDAP_BIND_DN!, LDAP_BIND_PASSWORD!, (error) => { + if (error) reject(error) + + resolve() + }) + }) + + await promise.catch((error) => { + throw new Error(error.message) + }) + } + + async getAllLDAPUsers() { + const promise = new Promise((resolve, reject) => { + const { LDAP_USERS_BASE_DN } = process.env + const filter = `(objectClass=*)` + + this.ldapClient.search( + LDAP_USERS_BASE_DN!, + { filter }, + (error, result) => { + if (error) reject(error) + + const users: LDAPUser[] = [] + + result.on('searchEntry', (entry) => { + users.push({ + uid: entry.object.uid as string, + username: entry.object.username as string, + displayName: entry.object.displayname as string + }) + }) + + result.on('end', (result) => { + resolve(users) + }) + } + ) + }) + + return await promise + .then((res) => res) + .catch((error) => { + throw new Error(error.message) + }) + } + + async getAllLDAPGroups() { + const promise = new Promise((resolve, reject) => { + const { LDAP_GROUPS_BASE_DN } = process.env + + this.ldapClient.search(LDAP_GROUPS_BASE_DN!, {}, (error, result) => { + if (error) reject(error) + + const groups: LDAPGroup[] = [] + + result.on('searchEntry', (entry) => { + const members = + typeof entry.object.memberuid === 'string' + ? [entry.object.memberuid] + : entry.object.memberuid + groups.push({ + name: entry.object.cn as string, + members + }) + }) + + result.on('end', (result) => { + resolve(groups) + }) + }) + }) + + return await promise + .then((res) => res) + .catch((error) => { + throw new Error(error.message) + }) + } + + async verifyUser(username: string, password: string) { + const promise = new Promise((resolve, reject) => { + const { LDAP_USERS_BASE_DN } = process.env + const filter = `(username=${username})` + + this.ldapClient.search( + LDAP_USERS_BASE_DN!, + { filter }, + (error, result) => { + if (error) reject(error) + + const items: any = [] + + result.on('searchEntry', (entry) => { + items.push(entry.object) + }) + + result.on('end', (result) => { + if (result?.status !== 0 || items.length === 0) return reject() + + // pick the first found + const user = items[0] + + this.ldapClient.bind(user.dn, password, (error) => { + if (error) return reject(error) + + resolve(true) + }) + }) + } + ) + }) + + return await promise + .then(() => true) + .catch(() => { + throw new Error('Invalid password.') + }) + } +} diff --git a/api/src/utils/verifyEnvVariables.ts b/api/src/utils/verifyEnvVariables.ts index 9c75852..e164dc5 100644 --- a/api/src/utils/verifyEnvVariables.ts +++ b/api/src/utils/verifyEnvVariables.ts @@ -8,6 +8,11 @@ export enum ModeType { Desktop = 'desktop' } +export enum AuthProviderType { + LDAP = 'ldap', + Internal = 'internal' +} + export enum ProtocolType { HTTP = 'http', HTTPS = 'https' @@ -64,6 +69,8 @@ export const verifyEnvVariables = (): ReturnCode => { errors.push(...verifyExecutablePaths()) + errors.push(...verifyLDAPVariables()) + if (errors.length) { process.logger?.error( `Invalid environment variable(s) provided: \n${errors.join('\n')}` @@ -104,13 +111,24 @@ const verifyMODE = (): string[] => { } if (process.env.MODE === ModeType.Server) { - const { DB_CONNECT } = process.env + const { DB_CONNECT, AUTH_MECHANISM } = process.env - if (process.env.NODE_ENV !== 'test') + if (process.env.NODE_ENV !== 'test') { if (!DB_CONNECT) errors.push( `- DB_CONNECT is required for PROTOCOL '${ModeType.Server}'` ) + + if (AUTH_MECHANISM) { + const authMechanismTypes = Object.values(AuthProviderType) + if (!authMechanismTypes.includes(AUTH_MECHANISM as AuthProviderType)) + errors.push( + `- AUTH_MECHANISM '${AUTH_MECHANISM}'\n - valid options ${authMechanismTypes}` + ) + } else { + process.env.AUTH_MECHANISM = DEFAULTS.AUTH_MECHANISM + } + } } return errors @@ -280,8 +298,56 @@ const verifyExecutablePaths = () => { return errors } +const verifyLDAPVariables = () => { + const errors: string[] = [] + const { + LDAP_URL, + LDAP_BIND_DN, + LDAP_BIND_PASSWORD, + LDAP_USERS_BASE_DN, + LDAP_GROUPS_BASE_DN, + MODE, + AUTH_MECHANISM + } = process.env + + if (MODE === ModeType.Server && AUTH_MECHANISM === AuthProviderType.LDAP) { + if (!LDAP_URL) { + errors.push( + `- LDAP_URL is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'` + ) + } + + if (!LDAP_BIND_DN) { + errors.push( + `- LDAP_BIND_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'` + ) + } + + if (!LDAP_BIND_PASSWORD) { + errors.push( + `- LDAP_BIND_PASSWORD is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'` + ) + } + + if (!LDAP_USERS_BASE_DN) { + errors.push( + `- LDAP_USERS_BASE_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'` + ) + } + + if (!LDAP_GROUPS_BASE_DN) { + errors.push( + `- LDAP_GROUPS_BASE_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'` + ) + } + } + + return errors +} + const DEFAULTS = { MODE: ModeType.Desktop, + AUTH_MECHANISM: AuthProviderType.Internal, PROTOCOL: ProtocolType.HTTP, PORT: '5000', HELMET_COEP: HelmetCoepType.TRUE, diff --git a/api/tsoa.json b/api/tsoa.json index 048211a..50ce955 100644 --- a/api/tsoa.json +++ b/api/tsoa.json @@ -15,6 +15,10 @@ "name": "Auth", "description": "Operations about auth" }, + { + "name": "Auth_Config", + "description": "Operations about external auth providers" + }, { "name": "Client", "description": "Operations about clients" diff --git a/web/src/containers/Settings/authConfig.tsx b/web/src/containers/Settings/authConfig.tsx new file mode 100644 index 0000000..262dad8 --- /dev/null +++ b/web/src/containers/Settings/authConfig.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react' +import axios from 'axios' +import { + Box, + Grid, + CircularProgress, + Card, + CardHeader, + Divider, + CardContent, + TextField, + CardActions, + Button, + Typography +} from '@mui/material' +import { toast } from 'react-toastify' + +const AuthConfig = () => { + const [isLoading, setIsLoading] = useState(false) + const [authDetail, setAuthDetail] = useState({}) + + useEffect(() => { + setIsLoading(true) + axios + .get(`/SASjsApi/authConfig`) + .then((res: any) => { + setAuthDetail(res.data) + }) + .catch((err) => { + toast.error('Failed: ' + err.response?.data || err.text, { + theme: 'dark', + position: toast.POSITION.BOTTOM_RIGHT + }) + }) + .finally(() => setIsLoading(false)) + }, []) + + const synchronizeWithLDAP = () => { + setIsLoading(true) + axios + .post(`/SASjsApi/authConfig/synchronizeWithLDAP`) + .then((res: any) => { + const { userCount, groupCount } = res.data + toast.success( + `Imported ${userCount} ${ + userCount > 1 ? 'users' : 'user' + } and ${groupCount} ${groupCount > 1 ? 'groups' : 'group'}`, + { + theme: 'dark', + position: toast.POSITION.BOTTOM_RIGHT + } + ) + }) + .catch((err) => { + toast.error('Failed: ' + err.response?.data || err.text, { + theme: 'dark', + position: toast.POSITION.BOTTOM_RIGHT + }) + }) + .finally(() => setIsLoading(false)) + } + + return isLoading ? ( + + ) : ( + + {Object.entries(authDetail).length === 0 && ( + No external Auth Provider is used + )} + {authDetail.ldap && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) +} + +export default AuthConfig diff --git a/web/src/containers/Settings/index.tsx b/web/src/containers/Settings/index.tsx index 9cbb183..064154f 100644 --- a/web/src/containers/Settings/index.tsx +++ b/web/src/containers/Settings/index.tsx @@ -7,6 +7,7 @@ import TabPanel from '@mui/lab/TabPanel' import Permission from './permission' import Profile from './profile' +import AuthConfig from './authConfig' import { AppContext, ModeType } from '../../context/appContext' import PermissionsContextProvider from '../../context/permissionsContext' @@ -59,6 +60,9 @@ const Settings = () => { {appContext.mode === ModeType.Server && ( )} + {appContext.mode === ModeType.Server && appContext.isAdmin && ( + + )} @@ -69,6 +73,9 @@ const Settings = () => { + + + )