Skip to content

Commit 216b5a6

Browse files
authored
Merge pull request #199 from VKCOM/e.khalilov/support-ldap-groups/QA-16111
Support LDAP groups
2 parents 1b69e9f + 2f59e46 commit 216b5a6

File tree

7 files changed

+268
-109
lines changed

7 files changed

+268
-109
lines changed

.eslintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"dot-location": [2, "property"], // defaults to "object"
2626
"dot-notation": 2,
2727
"eqeqeq": [2, "smart"], // `2` is recommended
28-
"guard-for-in": 2,
28+
"guard-for-in": 0,
2929
"no-alert": 1, // `2` is recommended
3030
"no-caller": 2,
3131
"no-div-regex": 2,

lib/cli/auth-ldap/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ export const builder = function(yargs) {
6767
, default: process.env.LDAP_USERNAME_FIELD || 'cn'
6868
, demand: true
6969
})
70+
.option('ldap-privilege-mapping', {
71+
describe: 'LDAP group to privilege mapping in JSON format (e.g. \'{"admin_group":"admin","user_group":"user"}\').'
72+
, type: 'string'
73+
, default: process.env.LDAP_PRIVILEGE_MAPPING || '{}'
74+
})
7075
.option('port', {
7176
alias: 'p'
7277
, describe: 'The port to bind to.'
@@ -130,6 +135,7 @@ export const handler = function(argv) {
130135
, username: {
131136
field: argv.ldapUsernameField
132137
}
138+
, privilegeMapping: argv.ldapPrivilegeMapping ? JSON.parse(argv.ldapPrivilegeMapping) : {}
133139
}
134140
})
135141
}

lib/db/api.js

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -690,28 +690,28 @@ export const setLockOnDevices = function(serials, lock) {
690690
*/
691691
function setLockOnUser(email, state) {
692692
return db.connect().then(client => {
693-
return client.collection('users').findOne({ email: email }).then(oldDoc => {
693+
return client.collection('users').findOne({email: email}).then(oldDoc => {
694694
if (!oldDoc || !oldDoc.groups) {
695-
throw new Error(`User with email ${email} not found or groups field is missing.`);
695+
throw new Error(`User with email ${email} not found or groups field is missing.`)
696696
}
697697
return client.collection('users').updateOne(
698-
{ email: email },
698+
{email: email},
699699
{
700700
$set: {
701701
'groups.lock': oldDoc.groups.lock !== state ? state : oldDoc.groups.lock
702702
}
703703
}
704704
)
705-
.then(updateStats => {
706-
return client.collection('users').findOne({ email: email }).then(newDoc => {
707-
updateStats.changes = [
708-
{ new_val: { ...newDoc }, old_val: { ...oldDoc } }
709-
];
710-
return updateStats;
711-
});
712-
});
713-
});
714-
});
705+
.then(updateStats => {
706+
return client.collection('users').findOne({email: email}).then(newDoc => {
707+
updateStats.changes = [
708+
{new_val: {...newDoc}, old_val: {...oldDoc}}
709+
]
710+
return updateStats
711+
})
712+
})
713+
})
714+
})
715715
}
716716

717717

@@ -1599,8 +1599,8 @@ export const deleteUserGroup = function(id) {
15991599
}
16001600

16011601
// dbapi.createUser = function(email, name, ip) {
1602-
export const createUser = function(email, name, ip) {
1603-
return trace('createUser', {email, name, ip}, () => {
1602+
export const createUser = function(email, name, ip, privilege) {
1603+
return trace('createUser', {email, name, ip, privilege}, () => {
16041604
return getRootGroup().then(function(group) {
16051605
return loadUser(group.owner.email).then(function(adminUser) {
16061606
let userObj = {
@@ -1613,7 +1613,7 @@ export const createUser = function(email, name, ip) {
16131613
, forwards: []
16141614
, settings: {}
16151615
, acceptedPolicy: false
1616-
, privilege: adminUser ? apiutil.USER : apiutil.ADMIN
1616+
, privilege: privilege || (adminUser ? apiutil.USER : apiutil.ADMIN)
16171617
, groups: {
16181618
subscribed: []
16191619
, lock: false
@@ -1658,17 +1658,20 @@ export const createUser = function(email, name, ip) {
16581658
export const saveUserAfterLogin = function(user) {
16591659
return trace('saveUserAfterLogin', {user}, () => {
16601660
return db.connect().then(client => {
1661-
return client.collection('users').updateOne({email: user.email},
1662-
{
1663-
$set: {
1664-
name: user.name
1665-
, ip: user.ip
1666-
, lastLoggedInAt: getNow()
1667-
}
1668-
})
1661+
const updateData = {
1662+
name: user.name
1663+
, ip: user.ip
1664+
, lastLoggedInAt: getNow()
1665+
}
1666+
1667+
if (user.privilege) {
1668+
updateData.privilege = user.privilege
1669+
}
1670+
1671+
return client.collection('users').updateOne({email: user.email}, {$set: updateData})
16691672
.then(function(stats) {
16701673
if (stats.modifiedCount === 0) {
1671-
return createUser(user.email, user.name, user.ip)
1674+
return createUser(user.email, user.name, user.ip, user.privilege)
16721675
}
16731676
return stats
16741677
})

lib/units/api/helpers/securityHandlers.js

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import * as dbapi from '../../../db/api.js'
33
import * as jwtutil from '../../../util/jwtutil.js'
44
import logger from '../../../util/logger.js'
55
import * as apiutil from '../../../util/apiutil.js'
6+
67
const log = logger.createLogger('api:helpers:securityHandlers')
8+
let upsertQueue
9+
710
// Specifications: https://tools.ietf.org/html/rfc6750#section-2.1
811
async function accessTokenAuth(req) {
912
let operationTag
@@ -13,6 +16,15 @@ async function accessTokenAuth(req) {
1316
else {
1417
operationTag = 'not-swagger'
1518
}
19+
20+
const isNotAllowed = (user) => !user || (
21+
user.privilege === apiutil.USER && operationTag.indexOf('admin') > -1
22+
)
23+
const forbidden = {
24+
status: 403
25+
, message: 'Forbidden: privileged operation (admin)'
26+
}
27+
1628
if (req.headers.authorization) {
1729
const authHeader = req.headers.authorization.split(' ')
1830
const format = authHeader[0]
@@ -34,8 +46,7 @@ async function accessTokenAuth(req) {
3446
try {
3547
let data
3648
try {
37-
const jwt = tokenId
38-
data = jwtutil.decode(jwt, req.options.secret)
49+
data = jwtutil.decode(tokenId, req.options.secret)
3950
}
4051
catch(e) {
4152
const token = await dbapi.loadAccessToken(tokenId)
@@ -53,25 +64,35 @@ async function accessTokenAuth(req) {
5364
, message: 'Bad token'
5465
}
5566
}
67+
68+
await upsertQueue
5669
const user = await dbapi.loadUser(data.email)
70+
5771
if (user) {
58-
if (user.privilege === apiutil.USER && operationTag.indexOf('admin') > -1) {
59-
throw {
60-
status: 403
61-
, message: 'Forbidden: privileged operation (admin)'
62-
}
72+
if (isNotAllowed(user)) {
73+
throw forbidden
6374
}
6475
req.user = user
6576
req.internalJwt = tokenId
6677
return true
6778
}
6879
else {
69-
await dbapi.saveUserAfterLogin({
80+
// Solve the problem with asynchronous requests (db duplicate key)
81+
// when we get many requests that require upsert (updateOne -> not modified -> insert)
82+
upsertQueue = (upsertQueue || Promise.resolve()).then(() => dbapi.saveUserAfterLogin({
7083
name: data.name
7184
, email: data.email
7285
, ip: req.ip
73-
})
86+
, ...(data.privilege && {privilege: data.privilege})
87+
}))
88+
89+
await upsertQueue
90+
7491
const user = await dbapi.loadUser(data.email)
92+
if (isNotAllowed(user)) {
93+
throw forbidden
94+
}
95+
7596
req.user = user
7697
req.internalJwt = tokenId
7798
return true
@@ -112,11 +133,8 @@ async function accessTokenAuth(req) {
112133
try {
113134
const user = dbapi.loadUser(data.email)
114135
if (user) {
115-
if (user.privilege === apiutil.USER && operationTag.indexOf('admin') > -1) {
116-
throw {
117-
status: 403
118-
, message: 'Forbidden: privileged operation (admin)'
119-
}
136+
if (isNotAllowed(user)) {
137+
throw forbidden
120138
}
121139
req.user = user
122140
return true

lib/units/auth/ldap.js

Lines changed: 79 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,24 @@ import * as jwtutil from '../../util/jwtutil.js'
1111
import * as pathutil from '../../util/pathutil.cjs'
1212
import lifecycle from '../../util/lifecycle.js'
1313
import rateLimitConfig from '../ratelimit/index.js'
14-
import {ONE_DAY} from '../../util/apiutil.js'
14+
import apiutil, {ONE_DAY} from '../../util/apiutil.js'
15+
16+
const ALLOWED_LDAP_PRIVILEGES = [apiutil.ADMIN, apiutil.USER]
1517
export default (function(options) {
1618
const log = logger.createLogger('auth-ldap')
19+
20+
if (options.ldap.privilegeMapping) {
21+
for (const key in options.ldap.privilegeMapping) {
22+
if (!ALLOWED_LDAP_PRIVILEGES.includes(options.ldap.privilegeMapping[key])) {
23+
log.fatal(`Unknown privilege in --ldap-privilege-mapping: { ${key}: ${options.ldap.privilegeMapping[key]} }`)
24+
process.exit(1)
25+
}
26+
27+
options.ldap.privilegeMapping[key.toLowerCase()] = options.ldap.privilegeMapping[key]
28+
}
29+
}
30+
31+
1732
let app = express()
1833
let server = Promise.promisifyAll(http.createServer(app))
1934
lifecycle.observe(function() {
@@ -57,65 +72,73 @@ export default (function(options) {
5772
res.sendFile(pathutil.reactFrontend('dist/auth/auth-ldap.html'))
5873
})
5974
app.post('/auth/api/v1/ldap', function(req, res) {
60-
var log = logger.createLogger('auth-ldap')
75+
const log = logger.createLogger('auth-ldap')
6176
log.setLocalIdentifier(req.ip)
62-
switch (req.accepts(['json'])) {
63-
case 'json':
64-
requtil.validate(req, function() {
65-
req.checkBody('username').notEmpty()
66-
req.checkBody('password').notEmpty()
77+
if (req.accepts(['json']) !== 'json') {
78+
res.send(406)
79+
return
80+
}
81+
82+
requtil.validate(req, function() {
83+
req.checkBody('username').notEmpty()
84+
req.checkBody('password').notEmpty()
85+
})
86+
.then(function() {
87+
return ldaputil.login(options.ldap, req.body.username, req.body.password)
6788
})
68-
.then(function() {
69-
return ldaputil.login(options.ldap, req.body.username, req.body.password)
89+
.then(function(user) {
90+
const userEmail = ldaputil.email(user)
91+
const privilege = ldaputil.determinePrivilege(user, options.ldap.privilegeMapping, userEmail)
92+
93+
const username = user[options.ldap.username.field]
94+
const usernameValue = Array.isArray(username) ? username[0] : username
95+
96+
97+
log.info('Authenticated "%s" with privilege "%s"', userEmail, privilege)
98+
99+
const token = jwtutil.encode({
100+
payload: {
101+
email: userEmail
102+
, name: usernameValue
103+
, privilege: privilege
104+
}
105+
, secret: options.secret
106+
, header: {
107+
exp: Date.now() + ONE_DAY
108+
}
70109
})
71-
.then(function(user) {
72-
log.info('Authenticated "%s"', ldaputil.email(user))
73-
var token = jwtutil.encode({
74-
payload: {
75-
email: ldaputil.email(user)
76-
, name: user[options.ldap.username.field]
77-
}
78-
, secret: options.secret
79-
, header: {
80-
exp: Date.now() + ONE_DAY
81-
}
110+
111+
res.status(200)
112+
.json({
113+
success: true
114+
, jwt: token
115+
, redirect: options.appUrl
82116
})
83-
res.status(200)
84-
.json({
85-
success: true
86-
, jwt: token
87-
, redirect: options.appUrl
88-
})
89-
})
90-
.catch(requtil.ValidationError, function(err) {
91-
res.status(400)
92-
.json({
93-
success: false
94-
, error: 'ValidationError'
95-
, validationErrors: err.errors
96-
})
97-
})
98-
.catch(ldaputil.InvalidCredentialsError, function(err) {
99-
log.warn('Authentication failure for "%s"', err.user)
100-
res.status(400)
101-
.json({
102-
success: false
103-
, error: 'InvalidCredentialsError'
104-
})
105-
})
106-
.catch(function(err) {
107-
log.error('Unexpected error', err.stack)
108-
res.status(500)
109-
.json({
110-
success: false
111-
, error: 'ServerError'
112-
})
113-
})
114-
break
115-
default:
116-
res.send(406)
117-
break
118-
}
117+
})
118+
.catch(requtil.ValidationError, function(err) {
119+
res.status(400)
120+
.json({
121+
success: false
122+
, error: 'ValidationError'
123+
, validationErrors: err.errors
124+
})
125+
})
126+
.catch(ldaputil.InvalidCredentialsError, function(err) {
127+
log.warn('Authentication failure for "%s"', err.user)
128+
res.status(400)
129+
.json({
130+
success: false
131+
, error: 'InvalidCredentialsError'
132+
})
133+
})
134+
.catch(function(err) {
135+
log.error('Unexpected error', err.stack)
136+
res.status(500)
137+
.json({
138+
success: false
139+
, error: 'ServerError'
140+
})
141+
})
119142
})
120143
server.listen(options.port)
121144
log.info('Listening on port %d', options.port)

lib/units/websocket/middleware/auth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default (function(options) {
1111
const tokenRaw = socket.handshake.auth.token
1212
const token = jwtutil.decode(tokenRaw, options.secret)
1313
req.internalJwt = tokenRaw
14-
return dbapi.loadUser(token.email)
14+
return !token?.email ? next(new Error('Invalid user')) : dbapi.loadUser(token.email)
1515
.then(function(user) {
1616
if (user) {
1717
req.user = user

0 commit comments

Comments
 (0)