1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 19:34:34 +00:00

feat: implemented LDAP authentication

This commit is contained in:
2022-09-29 18:41:28 +05:00
parent 375f924f45
commit f915c51b07
21 changed files with 1001 additions and 19 deletions

View File

@@ -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://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# AUTH_PROVIDERS options: [ldap|internal] default: `internal`
AUTH_PROVIDERS=
## ENV variables required for AUTH_MECHANISM: `ldap`
LDAP_URL= <LDAP_SERVER_URL>
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
LDAP_BIND_PASSWORD = <password>
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
# If enabled, be sure to also configure the WHITELIST of third party servers.
CORS=

View File

@@ -14,6 +14,14 @@ HELMET_COEP=[true|false] if omitted HELMET default will be used
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
AUTH_PROVIDERS=[ldap|internal] default considered as internal
LDAP_URL= <LDAP_SERVER_URL>
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
LDAP_BIND_PASSWORD = <password>
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
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

223
api/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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'

View File

@@ -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
}

View File

@@ -1,4 +1,5 @@
export * from './auth'
export * from './authConfig'
export * from './client'
export * from './code'
export * from './drive'

View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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<IGroupDocument>({
type: String,
default: 'Group description.'
},
authProvider: {
type: String,
enum: AuthProviderType,
default: 'internal'
},
isActive: {
type: Boolean,
default: true

View File

@@ -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<IUserDocument>({
type: String,
required: true
},
authProvider: {
type: String,
enum: AuthProviderType,
default: 'internal'
},
isAdmin: {
type: Boolean,
default: false

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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'

163
api/src/utils/ldapClient.ts Normal file
View File

@@ -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<void>((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<LDAPUser[]>((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<LDAPGroup[]>((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<boolean>((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.')
})
}
}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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<any>({})
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 ? (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
) : (
<Box>
{Object.entries(authDetail).length === 0 && (
<Typography>No external Auth Provider is used</Typography>
)}
{authDetail.ldap && (
<Card>
<CardHeader title="LDAP Authentication" />
<Divider />
<CardContent>
<Grid container spacing={4}>
<Grid item md={6} xs={12}>
<TextField
fullWidth
label="LDAP_URL"
name="LDAP_URL"
value={authDetail.ldap.LDAP_URL}
variant="outlined"
disabled
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
label="LDAP_BIND_DN"
name="LDAP_BIND_DN"
value={authDetail.ldap.LDAP_BIND_DN}
variant="outlined"
disabled={true}
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
label="LDAP_BIND_PASSWORD"
name="LDAP_BIND_PASSWORD"
type="password"
value={authDetail.ldap.LDAP_BIND_PASSWORD}
variant="outlined"
disabled
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
label="LDAP_USERS_BASE_DN"
name="LDAP_USERS_BASE_DN"
value={authDetail.ldap.LDAP_USERS_BASE_DN}
variant="outlined"
disabled={true}
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
label="LDAP_GROUPS_BASE_DN"
name="LDAP_GROUPS_BASE_DN"
value={authDetail.ldap.LDAP_GROUPS_BASE_DN}
variant="outlined"
disabled={true}
/>
</Grid>
</Grid>
</CardContent>
<Divider />
<CardActions>
<Button
type="submit"
variant="contained"
onClick={synchronizeWithLDAP}
>
Synchronize
</Button>
</CardActions>
</Card>
)}
</Box>
)
}
export default AuthConfig

View File

@@ -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 && (
<StyledTab label="Permissions" value="permission" />
)}
{appContext.mode === ModeType.Server && appContext.isAdmin && (
<StyledTab label="Auth Config" value="auth_config" />
)}
</TabList>
</Box>
<StyledTabpanel value="profile">
@@ -69,6 +73,9 @@ const Settings = () => {
<Permission />
</PermissionsContextProvider>
</StyledTabpanel>
<StyledTabpanel value="auth_config">
<AuthConfig />
</StyledTabpanel>
</TabContext>
</Box>
)