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 9a0a131..e5fb80e 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -19,6 +19,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", @@ -40,6 +41,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", @@ -2034,6 +2036,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", @@ -2209,6 +2220,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", @@ -2464,6 +2480,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", @@ -2475,6 +2499,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", @@ -2636,6 +2668,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", @@ -4006,6 +4049,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", @@ -6718,6 +6769,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", @@ -8007,6 +8087,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", @@ -9605,6 +9693,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", @@ -11497,6 +11622,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", @@ -11671,6 +11805,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", @@ -11869,6 +12008,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", @@ -11880,6 +12027,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", @@ -12007,6 +12159,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", @@ -13057,6 +13217,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", @@ -15097,6 +15262,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", @@ -16068,6 +16256,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", @@ -17263,6 +17456,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 56165ed..5a27bea 100644 --- a/api/package.json +++ b/api/package.json @@ -58,6 +58,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", @@ -76,6 +77,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..2fdd04e 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -622,6 +622,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 +1839,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/group.ts b/api/src/controllers/group.ts index c5b8681..878a9c0 100644 --- a/api/src/controllers/group.ts +++ b/api/src/controllers/group.ts @@ -12,6 +12,7 @@ import { import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group' import User from '../model/User' +import { AuthProviderType } from '../utils' import { UserResponse } from './user' export interface GroupResponse { @@ -147,12 +148,14 @@ export class GroupController { @Delete('{groupId}') public async deleteGroup(@Path() groupId: number) { const group = await Group.findOne({ groupId }) - if (group) return await group.remove() - throw { - code: 404, - status: 'Not Found', - message: 'Group not found.' - } + if (!group) + throw { + code: 404, + status: 'Not Found', + message: 'Group not found.' + } + + return await group.remove() } } @@ -248,6 +251,13 @@ const updateUsersListInGroup = async ( message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.` } + if (group.authProvider !== AuthProviderType.Internal) + throw { + code: 405, + status: 'Method Not Allowed', + message: `Can't add/remove user to group created by external auth provider.` + } + const user = await User.findOne({ id: userId }) if (!user) throw { @@ -256,6 +266,13 @@ const updateUsersListInGroup = async ( message: 'User not found.' } + if (user.authProvider !== AuthProviderType.Internal) + throw { + code: 405, + status: 'Method Not Allowed', + message: `Can't add/remove user to group created by external auth provider.` + } + const updatedGroup = action === 'addUser' ? await group.addUser(user) 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/user.ts b/api/src/controllers/user.ts index f410853..0c96b55 100644 --- a/api/src/controllers/user.ts +++ b/api/src/controllers/user.ts @@ -17,7 +17,12 @@ import { import { desktopUser } from '../middlewares' import User, { UserPayload } from '../model/User' -import { getUserAutoExec, updateUserAutoExec, ModeType } from '../utils' +import { + getUserAutoExec, + updateUserAutoExec, + ModeType, + AuthProviderType +} from '../utils' import { GroupResponse } from './group' export interface UserResponse { @@ -211,7 +216,11 @@ const createUser = async (data: UserPayload): Promise => { // Checking if user is already in the database const usernameExist = await User.findOne({ username }) - if (usernameExist) throw new Error('Username already exists.') + if (usernameExist) + throw { + code: 409, + message: 'Username already exists.' + } // Hash passwords const hashPassword = User.hashPassword(password) @@ -255,7 +264,11 @@ const getUser = async ( 'groupId name description -_id' )) as unknown as UserDetailsResponse - if (!user) throw new Error('User is not found.') + if (!user) + throw { + code: 404, + message: 'User is not found.' + } return { id: user.id, @@ -284,6 +297,19 @@ const updateUser = async ( const params: any = { displayName, isAdmin, isActive, autoExec } + const user = await User.findOne(findBy) + + if ( + user?.authProvider !== AuthProviderType.Internal && + (username !== user?.username || displayName !== user?.displayName) + ) { + throw { + code: 405, + message: + 'Can not update username and display name of user that is created by an external auth provider.' + } + } + if (username) { // Checking if user is already in the database const usernameExist = await User.findOne({ username }) @@ -292,7 +318,10 @@ const updateUser = async ( (findBy.id && usernameExist.id != findBy.id) || (findBy.username && usernameExist.username != findBy.username) ) - throw new Error('Username already exists.') + throw { + code: 409, + message: 'Username already exists.' + } } params.username = username } @@ -305,7 +334,10 @@ const updateUser = async ( const updatedUser = await User.findOneAndUpdate(findBy, params, { new: true }) if (!updatedUser) - throw new Error(`Unable to find user with ${findBy.id || findBy.username}`) + throw { + code: 404, + message: `Unable to find user with ${findBy.id || findBy.username}` + } return { id: updatedUser.id, @@ -332,11 +364,19 @@ const deleteUser = async ( { password }: { password?: string } ) => { const user = await User.findOne(findBy) - if (!user) throw new Error('User is not found.') + if (!user) + throw { + code: 404, + message: 'User is not found.' + } if (!isAdmin) { const validPass = user.comparePassword(password!) - if (!validPass) throw new Error('Invalid password.') + if (!validPass) + throw { + code: 401, + message: 'Invalid password.' + } } await User.deleteOne(findBy) diff --git a/api/src/controllers/web.ts b/api/src/controllers/web.ts index ada7550..e496235 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_PROVIDERS === 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/model/Group.ts b/api/src/model/Group.ts index 3185f44..bf06c9a 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' @@ -27,6 +28,7 @@ interface IGroupDocument extends GroupPayload, Document { groupId: number isActive: boolean users: Schema.Types.ObjectId[] + authProvider?: AuthProviderType } interface IGroup extends IGroupDocument { @@ -46,6 +48,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..e405317 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 { /** @@ -42,6 +43,7 @@ interface IUserDocument extends UserPayload, Document { autoExec: string groups: Schema.Types.ObjectId[] tokens: [{ [key: string]: string }] + authProvider?: AuthProviderType } export interface IUser extends IUserDocument { @@ -67,6 +69,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..f8712ee 100644 --- a/api/src/routes/api/group.ts +++ b/api/src/routes/api/group.ts @@ -18,11 +18,7 @@ groupRouter.post( const response = await controller.createGroup(body) res.send(response) } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } } ) @@ -33,11 +29,7 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => { const response = await controller.getAllGroups() res.send(response) } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } }) @@ -49,11 +41,7 @@ groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => { const response = await controller.getGroup(parseInt(groupId)) res.send(response) } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } }) @@ -71,11 +59,7 @@ groupRouter.get( const response = await controller.getGroupByGroupName(name) res.send(response) } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } } ) @@ -95,11 +79,7 @@ groupRouter.post( ) res.send(response) } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } } ) @@ -119,11 +99,7 @@ groupRouter.delete( ) res.send(response) } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } } ) @@ -140,11 +116,7 @@ groupRouter.delete( await controller.deleteGroup(parseInt(groupId)) res.status(200).send('Group Deleted!') } catch (err: any) { - const statusCode = err.code - - delete err.code - - res.status(statusCode).send(err.message) + res.status(err.code).send(err.message) } } ) 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/spec/group.spec.ts b/api/src/routes/api/spec/group.spec.ts index fe1072e..56af21a 100644 --- a/api/src/routes/api/spec/group.spec.ts +++ b/api/src/routes/api/spec/group.spec.ts @@ -4,8 +4,13 @@ import { MongoMemoryServer } from 'mongodb-memory-server' import request from 'supertest' import appPromise from '../../../app' import { UserController, GroupController } from '../../../controllers/' -import { generateAccessToken, saveTokensInDB } from '../../../utils' -import { PUBLIC_GROUP_NAME } from '../../../model/Group' +import { + generateAccessToken, + saveTokensInDB, + AuthProviderType +} from '../../../utils' +import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group' +import User from '../../../model/User' const clientId = 'someclientID' const adminUser = { @@ -560,6 +565,46 @@ describe('group', () => { `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.` ) }) + + it('should respond with Method Not Allowed if group is created by an external authProvider', async () => { + const dbGroup = await Group.create({ + ...group, + authProvider: AuthProviderType.LDAP + }) + const dbUser = await userController.createUser({ + ...user, + username: 'ldapGroupUser' + }) + + const res = await request(app) + .post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(405) + + expect(res.text).toEqual( + `Can't add/remove user to group created by external auth provider.` + ) + }) + + it('should respond with Method Not Allowed if user is created by an external authProvider', async () => { + const dbGroup = await groupController.createGroup(group) + const dbUser = await User.create({ + ...user, + username: 'ldapUser', + authProvider: AuthProviderType.LDAP + }) + + const res = await request(app) + .post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(405) + + expect(res.text).toEqual( + `Can't add/remove user to group created by external auth provider.` + ) + }) }) describe('RemoveUser', () => { @@ -611,6 +656,46 @@ describe('group', () => { expect(res.body.groups).toEqual([]) }) + it('should respond with Method Not Allowed if group is created by an external authProvider', async () => { + const dbGroup = await Group.create({ + ...group, + authProvider: AuthProviderType.LDAP + }) + const dbUser = await userController.createUser({ + ...user, + username: 'removeLdapGroupUser' + }) + + const res = await request(app) + .delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(405) + + expect(res.text).toEqual( + `Can't add/remove user to group created by external auth provider.` + ) + }) + + it('should respond with Method Not Allowed if user is created by an external authProvider', async () => { + const dbGroup = await groupController.createGroup(group) + const dbUser = await User.create({ + ...user, + username: 'removeLdapUser', + authProvider: AuthProviderType.LDAP + }) + + const res = await request(app) + .delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(405) + + expect(res.text).toEqual( + `Can't add/remove user to group created by external auth provider.` + ) + }) + it('should respond with Unauthorized if access token is not present', async () => { const res = await request(app) .delete('/SASjsApi/group/123/123') diff --git a/api/src/routes/api/spec/user.spec.ts b/api/src/routes/api/spec/user.spec.ts index 12e68d5..da8b829 100644 --- a/api/src/routes/api/spec/user.spec.ts +++ b/api/src/routes/api/spec/user.spec.ts @@ -4,7 +4,12 @@ import { MongoMemoryServer } from 'mongodb-memory-server' import request from 'supertest' import appPromise from '../../../app' import { UserController, GroupController } from '../../../controllers/' -import { generateAccessToken, saveTokensInDB } from '../../../utils' +import { + generateAccessToken, + saveTokensInDB, + AuthProviderType +} from '../../../utils' +import User from '../../../model/User' const clientId = 'someclientID' const adminUser = { @@ -110,16 +115,16 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden if username is already present', async () => { + it('should respond with Conflict if username is already present', async () => { await controller.createUser(user) const res = await request(app) .post('/SASjsApi/user') .auth(adminAccessToken, { type: 'bearer' }) .send(user) - .expect(403) + .expect(409) - expect(res.text).toEqual('Error: Username already exists.') + expect(res.text).toEqual('Username already exists.') expect(res.body).toEqual({}) }) @@ -226,6 +231,36 @@ describe('user', () => { .expect(400) }) + it('should respond with Method Not Allowed, when updating username of user created by an external auth provider', async () => { + const dbUser = await User.create({ + ...user, + authProvider: AuthProviderType.LDAP + }) + const accessToken = await generateAndSaveToken(dbUser!.id) + const newUsername = 'newUsername' + + await request(app) + .patch(`/SASjsApi/user/${dbUser!.id}`) + .auth(accessToken, { type: 'bearer' }) + .send({ username: newUsername }) + .expect(405) + }) + + it('should respond with Method Not Allowed, when updating displayName of user created by an external auth provider', async () => { + const dbUser = await User.create({ + ...user, + authProvider: AuthProviderType.LDAP + }) + const accessToken = await generateAndSaveToken(dbUser!.id) + const newDisplayName = 'My new display Name' + + await request(app) + .patch(`/SASjsApi/user/${dbUser!.id}`) + .auth(accessToken, { type: 'bearer' }) + .send({ displayName: newDisplayName }) + .expect(405) + }) + it('should respond with Unauthorized if access token is not present', async () => { const res = await request(app) .patch('/SASjsApi/user/1234') @@ -254,7 +289,7 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden if username is already present', async () => { + it('should respond with Conflict if username is already present', async () => { const dbUser1 = await controller.createUser(user) const dbUser2 = await controller.createUser({ ...user, @@ -265,9 +300,9 @@ describe('user', () => { .patch(`/SASjsApi/user/${dbUser1.id}`) .auth(adminAccessToken, { type: 'bearer' }) .send({ username: dbUser2.username }) - .expect(403) + .expect(409) - expect(res.text).toEqual('Error: Username already exists.') + expect(res.text).toEqual('Username already exists.') expect(res.body).toEqual({}) }) @@ -349,7 +384,7 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden if username is already present', async () => { + it('should respond with Conflict if username is already present', async () => { const dbUser1 = await controller.createUser(user) const dbUser2 = await controller.createUser({ ...user, @@ -360,9 +395,9 @@ describe('user', () => { .patch(`/SASjsApi/user/by/username/${dbUser1.username}`) .auth(adminAccessToken, { type: 'bearer' }) .send({ username: dbUser2.username }) - .expect(403) + .expect(409) - expect(res.text).toEqual('Error: Username already exists.') + expect(res.text).toEqual('Username already exists.') expect(res.body).toEqual({}) }) }) @@ -446,7 +481,7 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden when user himself requests and password is incorrect', async () => { + it('should respond with Unauthorized when user himself requests and password is incorrect', async () => { const dbUser = await controller.createUser(user) const accessToken = await generateAndSaveToken(dbUser.id) @@ -454,9 +489,9 @@ describe('user', () => { .delete(`/SASjsApi/user/${dbUser.id}`) .auth(accessToken, { type: 'bearer' }) .send({ password: 'incorrectpassword' }) - .expect(403) + .expect(401) - expect(res.text).toEqual('Error: Invalid password.') + expect(res.text).toEqual('Invalid password.') expect(res.body).toEqual({}) }) @@ -528,7 +563,7 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden when user himself requests and password is incorrect', async () => { + it('should respond with Unauthorized when user himself requests and password is incorrect', async () => { const dbUser = await controller.createUser(user) const accessToken = await generateAndSaveToken(dbUser.id) @@ -536,9 +571,9 @@ describe('user', () => { .delete(`/SASjsApi/user/by/username/${dbUser.username}`) .auth(accessToken, { type: 'bearer' }) .send({ password: 'incorrectpassword' }) - .expect(403) + .expect(401) - expect(res.text).toEqual('Error: Invalid password.') + expect(res.text).toEqual('Invalid password.') expect(res.body).toEqual({}) }) }) @@ -652,16 +687,16 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden if userId is incorrect', async () => { + it('should respond with Not Found if userId is incorrect', async () => { await controller.createUser(user) const res = await request(app) .get('/SASjsApi/user/1234') .auth(adminAccessToken, { type: 'bearer' }) .send() - .expect(403) + .expect(404) - expect(res.text).toEqual('Error: User is not found.') + expect(res.text).toEqual('User is not found.') expect(res.body).toEqual({}) }) @@ -731,16 +766,16 @@ describe('user', () => { expect(res.body).toEqual({}) }) - it('should respond with Forbidden if username is incorrect', async () => { + it('should respond with Not Found if username is incorrect', async () => { await controller.createUser(user) const res = await request(app) .get('/SASjsApi/user/by/username/randomUsername') .auth(adminAccessToken, { type: 'bearer' }) .send() - .expect(403) + .expect(404) - expect(res.text).toEqual('Error: User is not found.') + expect(res.text).toEqual('User is not found.') expect(res.body).toEqual({}) }) }) diff --git a/api/src/routes/api/user.ts b/api/src/routes/api/user.ts index 20ce88c..d0ee7cc 100644 --- a/api/src/routes/api/user.ts +++ b/api/src/routes/api/user.ts @@ -23,7 +23,7 @@ userRouter.post('/', authenticateAccessToken, verifyAdmin, async (req, res) => { const response = await controller.createUser(body) res.send(response) } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } }) @@ -33,7 +33,7 @@ userRouter.get('/', authenticateAccessToken, async (req, res) => { const response = await controller.getAllUsers() res.send(response) } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } }) @@ -51,7 +51,7 @@ userRouter.get( const response = await controller.getUserByUsername(req, username) res.send(response) } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } } ) @@ -64,7 +64,7 @@ userRouter.get('/:userId', authenticateAccessToken, async (req, res) => { const response = await controller.getUser(req, parseInt(userId)) res.send(response) } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } }) @@ -91,7 +91,7 @@ userRouter.patch( const response = await controller.updateUserByUsername(username, body) res.send(response) } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } } ) @@ -113,7 +113,7 @@ userRouter.patch( const response = await controller.updateUser(parseInt(userId), body) res.send(response) } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } } ) @@ -141,7 +141,7 @@ userRouter.delete( await controller.deleteUserByUsername(username, data, user!.isAdmin) res.status(200).send('Account Deleted!') } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } } ) @@ -163,7 +163,7 @@ userRouter.delete( await controller.deleteUser(parseInt(userId), data, user!.isAdmin) res.status(200).send('Account Deleted!') } catch (err: any) { - res.status(403).send(err.toString()) + res.status(err.code).send(err.message) } } ) 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 = () => { + + + )