From f915c51b077a2b8c4099727355ed914ecd6364bd Mon Sep 17 00:00:00 2001 From: Sabir Hassan Date: Thu, 29 Sep 2022 18:41:28 +0500 Subject: [PATCH 1/5] feat: implemented LDAP authentication --- README.md | 12 +- api/.env.example | 8 + api/package-lock.json | 223 +++++++++++++++++++++ api/package.json | 2 + api/public/swagger.yaml | 61 ++++++ api/src/controllers/authConfig.ts | 185 +++++++++++++++++ api/src/controllers/index.ts | 1 + api/src/controllers/web.ts | 19 +- api/src/middlewares/desktop.ts | 14 +- api/src/model/Group.ts | 11 + api/src/model/User.ts | 11 + api/src/routes/api/authConfig.ts | 25 +++ api/src/routes/api/group.ts | 10 +- api/src/routes/api/index.ts | 9 + api/src/routes/api/user.ts | 33 ++- api/src/utils/index.ts | 1 + api/src/utils/ldapClient.ts | 163 +++++++++++++++ api/src/utils/verifyEnvVariables.ts | 70 ++++++- api/tsoa.json | 4 + web/src/containers/Settings/authConfig.tsx | 151 ++++++++++++++ web/src/containers/Settings/index.tsx | 7 + 21 files changed, 1001 insertions(+), 19 deletions(-) create mode 100644 api/src/controllers/authConfig.ts create mode 100644 api/src/routes/api/authConfig.ts create mode 100644 api/src/utils/ldapClient.ts create mode 100644 web/src/containers/Settings/authConfig.tsx 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 = () => { + + + ) From a14266077d3541c7a33b7635efa4208335e73519 Mon Sep 17 00:00:00 2001 From: Sabir Hassan Date: Fri, 30 Sep 2022 14:41:09 +0500 Subject: [PATCH 2/5] fix: no need to restrict api endpoints when ldap auth is applied --- api/src/controllers/group.ts | 35 +++++++++++++++--- api/src/controllers/user.ts | 54 ++++++++++++++++++++++++---- api/src/controllers/web.ts | 2 +- api/src/middlewares/desktop.ts | 14 +------- api/src/routes/api/group.ts | 52 +++++---------------------- api/src/routes/api/spec/user.spec.ts | 42 +++++++++++----------- api/src/routes/api/user.ts | 47 ++++++++++-------------- 7 files changed, 126 insertions(+), 120 deletions(-) diff --git a/api/src/controllers/group.ts b/api/src/controllers/group.ts index c5b8681..990d0f1 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,22 @@ 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.' + } + + if (group.authProvider !== AuthProviderType.Internal) { + throw { + code: 405, + status: 'Method Not Allowed', + message: 'Can not delete group created by an external auth provider.' + } } + + return await group.remove() } } @@ -248,6 +259,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 +274,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/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 b3b7d08..e496235 100644 --- a/api/src/controllers/web.ts +++ b/api/src/controllers/web.ts @@ -86,7 +86,7 @@ const login = async ( if (!user) throw new Error('Username is not found.') if ( - process.env.AUTH_MECHANISM === AuthProviderType.LDAP && + process.env.AUTH_PROVIDERS === AuthProviderType.LDAP && user.authProvider === AuthProviderType.LDAP ) { const ldapClient = await LDAPClient.init() diff --git a/api/src/middlewares/desktop.ts b/api/src/middlewares/desktop.ts index 4db2396..b2935fd 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, AuthProviderType } from '../utils' +import { ModeType } from '../utils' const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1 @@ -27,18 +27,6 @@ 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/routes/api/group.ts b/api/src/routes/api/group.ts index 7e83dff..f8712ee 100644 --- a/api/src/routes/api/group.ts +++ b/api/src/routes/api/group.ts @@ -1,17 +1,12 @@ import express from 'express' import { GroupController } from '../../controllers/' -import { - ldapRestrict, - authenticateAccessToken, - verifyAdmin -} from '../../middlewares' +import { authenticateAccessToken, verifyAdmin } from '../../middlewares' import { getGroupValidation, registerGroupValidation } from '../../utils' const groupRouter = express.Router() groupRouter.post( '/', - ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -23,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) } } ) @@ -38,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) } }) @@ -54,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) } }) @@ -76,18 +59,13 @@ 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) } } ) groupRouter.post( '/:groupId/:userId', - ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -101,18 +79,13 @@ 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) } } ) groupRouter.delete( '/:groupId/:userId', - ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -126,18 +99,13 @@ 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) } } ) groupRouter.delete( '/:groupId', - ldapRestrict, authenticateAccessToken, verifyAdmin, async (req, res) => { @@ -148,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/spec/user.spec.ts b/api/src/routes/api/spec/user.spec.ts index 12e68d5..b2e000f 100644 --- a/api/src/routes/api/spec/user.spec.ts +++ b/api/src/routes/api/spec/user.spec.ts @@ -110,16 +110,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({}) }) @@ -254,7 +254,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 +265,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 +349,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 +360,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 +446,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 +454,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 +528,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 +536,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 +652,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 +731,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 2baa54f..d0ee7cc 100644 --- a/api/src/routes/api/user.ts +++ b/api/src/routes/api/user.ts @@ -3,8 +3,7 @@ import { UserController } from '../../controllers/' import { authenticateAccessToken, verifyAdmin, - verifyAdminIfNeeded, - ldapRestrict + verifyAdminIfNeeded } from '../../middlewares' import { deleteUserValidation, @@ -15,24 +14,18 @@ import { const userRouter = express.Router() -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) +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) - 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(err.code).send(err.message) } -) +}) userRouter.get('/', authenticateAccessToken, async (req, res) => { const controller = new UserController() @@ -40,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) } }) @@ -58,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) } } ) @@ -71,13 +64,12 @@ 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) } }) userRouter.patch( '/by/username/:username', - ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -99,14 +91,13 @@ 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) } } ) userRouter.patch( '/:userId', - ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -122,14 +113,13 @@ 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) } } ) userRouter.delete( '/by/username/:username', - ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -151,14 +141,13 @@ 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) } } ) userRouter.delete( '/:userId', - ldapRestrict, authenticateAccessToken, verifyAdminIfNeeded, async (req, res) => { @@ -174,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) } } ) From 68f0c5c5884431e7e8f586dccf98132abebb193e Mon Sep 17 00:00:00 2001 From: Sabir Hassan Date: Sat, 1 Oct 2022 14:52:36 +0500 Subject: [PATCH 3/5] fix: do not throw error on deleting group when it is created by an external auth provider --- api/src/controllers/group.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/api/src/controllers/group.ts b/api/src/controllers/group.ts index 990d0f1..878a9c0 100644 --- a/api/src/controllers/group.ts +++ b/api/src/controllers/group.ts @@ -155,14 +155,6 @@ export class GroupController { message: 'Group not found.' } - if (group.authProvider !== AuthProviderType.Internal) { - throw { - code: 405, - status: 'Method Not Allowed', - message: 'Can not delete group created by an external auth provider.' - } - } - return await group.remove() } } From bbd7786c6ce13b374d896a45c23255b8fa3e8bd2 Mon Sep 17 00:00:00 2001 From: Sabir Hassan Date: Sat, 1 Oct 2022 15:06:55 +0500 Subject: [PATCH 4/5] fix: remove authProvider attribute from user and group payload interface --- api/public/swagger.yaml | 13 ------------- api/src/model/Group.ts | 6 +----- api/src/model/User.ts | 6 +----- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index f7f18d4..2fdd04e 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -318,11 +318,6 @@ components: - isAdmin type: object additionalProperties: false - AuthProviderType: - enum: - - ldap - - internal - type: string UserPayload: properties: displayName: @@ -336,10 +331,6 @@ 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' @@ -391,10 +382,6 @@ 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' diff --git a/api/src/model/Group.ts b/api/src/model/Group.ts index 0a0c8ef..bf06c9a 100644 --- a/api/src/model/Group.ts +++ b/api/src/model/Group.ts @@ -17,11 +17,6 @@ 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" @@ -33,6 +28,7 @@ interface IGroupDocument extends GroupPayload, Document { groupId: number isActive: boolean users: Schema.Types.ObjectId[] + authProvider?: AuthProviderType } interface IGroup extends IGroupDocument { diff --git a/api/src/model/User.ts b/api/src/model/User.ts index 86b626b..e405317 100644 --- a/api/src/model/User.ts +++ b/api/src/model/User.ts @@ -18,11 +18,6 @@ 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" @@ -48,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 { From 3fc06b80fcf882a63bc2b779fbd6996cecdc1cb5 Mon Sep 17 00:00:00 2001 From: Sabir Hassan Date: Sat, 1 Oct 2022 16:08:29 +0500 Subject: [PATCH 5/5] chore: add specs --- api/src/routes/api/spec/group.spec.ts | 89 ++++++++++++++++++++++++++- api/src/routes/api/spec/user.spec.ts | 37 ++++++++++- 2 files changed, 123 insertions(+), 3 deletions(-) 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 b2e000f..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 = { @@ -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')