From 0d327c1315ce44d23a91c520c33bf83685f44e76 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Fri, 7 Oct 2022 10:13:05 +0300 Subject: [PATCH] Added preauth API endpoint --- config/roles.json | 3 +- docs/api/openapi.yml | 61 ++++++++++++++++++++++++++ lib/api/2fa/webauthn.js | 46 +++++++++++++++----- lib/api/auth.js | 94 ++++++++++++++++++++++++++++++++++++++++- lib/user-handler.js | 67 ++++++++++++++++++++++++++++- package.json | 2 +- test/api-test.js | 24 +++++++++++ 7 files changed, 283 insertions(+), 14 deletions(-) diff --git a/config/roles.json b/config/roles.json index ad20e616..ffda9fba 100644 --- a/config/roles.json +++ b/config/roles.json @@ -296,7 +296,8 @@ }, "authentication": { - "read:own": ["*"] + "read:own": ["*"], + "create:own": ["*"] }, "userlisting": { diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 64d876a1..d888c54a 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -638,6 +638,67 @@ paths: application/json: schema: $ref: '#/components/schemas/AuthenticateResponse' + + /preauth: + post: + tags: + - Authentication + summary: Pre-auth check + description: Check if an username exists and can be used for authentication + operationId: preauth + requestBody: + content: + application/json: + schema: + required: + - username + type: object + properties: + username: + type: string + description: Username or E-mail address + scope: + type: string + description: 'Required scope. One of master, imap, smtp, pop3' + sess: + type: string + description: Session identifier for the logs + ip: + type: string + description: IP address for the logs + required: true + responses: + '200': + description: Success + content: + application/json: + schema: + required: + - success + - id + - username + - scope + - require2fa + type: object + properties: + success: + type: boolean + description: Indicates successful response + id: + type: string + description: ID of authenticated User + username: + type: string + description: Username of authenticated User + scope: + type: string + description: The scope this authentication is valid for + require2fa: + type: array + items: + type: string + description: List of enabled 2FA mechanisms + '/users/{user}/authlog': get: tags: diff --git a/lib/api/2fa/webauthn.js b/lib/api/2fa/webauthn.js index 99836e75..5aeef95d 100644 --- a/lib/api/2fa/webauthn.js +++ b/lib/api/2fa/webauthn.js @@ -4,7 +4,7 @@ const Joi = require('joi'); const ObjectId = require('mongodb').ObjectId; const tools = require('../../tools'); const roles = require('../../roles'); -const { sessSchema, sessIPSchema } = require('../../schemas'); +const { sessSchema, sessIPSchema, booleanSchema } = require('../../schemas'); module.exports = (db, server, userHandler) => { server.get( @@ -274,9 +274,9 @@ module.exports = (db, server, userHandler) => { // permissions check if (req.user && req.user === result.value.user) { - req.validate(roles.can(req.role).updateOwn('users')); + req.validate(roles.can(req.role).createOwn('authentication')); } else { - req.validate(roles.can(req.role).updateAny('users')); + req.validate(roles.can(req.role).createAny('authentication')); } let user = new ObjectId(result.value.user); @@ -319,6 +319,8 @@ module.exports = (db, server, userHandler) => { rpId: Joi.string().hostname().empty(''), + token: booleanSchema.default(false), + sess: sessSchema, ip: sessIPSchema }); @@ -338,21 +340,45 @@ module.exports = (db, server, userHandler) => { return next(); } - // permissions check + let permission; + if (req.user && req.user === result.value.user) { - req.validate(roles.can(req.role).updateOwn('users')); + permission = roles.can(req.role).createOwn('authentication'); } else { - req.validate(roles.can(req.role).updateAny('users')); + permission = roles.can(req.role).createAny('authentication'); } + // permissions check + req.validate(permission); + + // filter out unallowed fields + result.value = permission.filter(result.value); + let user = new ObjectId(result.value.user); - let response = await userHandler.webauthnAssertAuthentication(user, result.value); + let authData = await userHandler.webauthnAssertAuthentication(user, result.value); - res.json({ + let authResponse = { success: true, - response - }); + response: authData + }; + + if (result.value.token) { + try { + authResponse.token = await userHandler.generateAuthToken(user); + } catch (err) { + let response = { + error: err.message, + code: err.code || 'AuthFailed', + id: user.toString() + }; + res.status(403); + res.json(response); + return next(); + } + } + + res.json(permission.filter(authResponse)); return next(); }) diff --git a/lib/api/auth.js b/lib/api/auth.js index 22aabf83..6e3184e1 100644 --- a/lib/api/auth.js +++ b/lib/api/auth.js @@ -8,6 +8,96 @@ const roles = require('../roles'); const { nextPageCursorSchema, previousPageCursorSchema, pageNrSchema, sessSchema, sessIPSchema, booleanSchema } = require('../schemas'); module.exports = (db, server, userHandler) => { + server.post( + '/preauth', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); + + const schema = Joi.object().keys({ + username: Joi.alternatives() + .try( + Joi.string() + .lowercase() + .regex(/^[a-z0-9][a-z0-9.]+[a-z0-9]$/, 'username') + .min(3) + .max(30), + Joi.string().email({ tlds: false }) + ) + .required(), + + scope: Joi.string().default('master'), + + sess: sessSchema, + ip: sessIPSchema + }); + + const result = schema.validate(req.params, { + abortEarly: false, + convert: true + }); + + if (result.error) { + res.status(400); + res.json({ + error: result.error.message, + code: 'InputValidationError', + details: tools.validationErrors(result) + }); + return next(); + } + + let permission = roles.can(req.role).createAny('authentication'); + + // permissions check + req.validate(permission); + + // filter out unallowed fields + result.value = permission.filter(result.value); + + let authData, user; + + try { + [authData, user] = await userHandler.preAuth(result.value.username, result.value.scope); + } catch (err) { + let response = { + error: err.message, + code: err.code || 'AuthFailed' + }; + if (user) { + response.id = user.toString(); + } + res.status(403); + res.json(response); + return next(); + } + + if (!authData) { + let response = { + error: 'Authentication failed', + code: 'AuthFailed' + }; + if (user) { + response.id = user.toString(); + } + res.status(403); + res.json(response); + return next(); + } + + let preAuthResponse = { + success: true, + id: authData.user.toString(), + username: authData.username, + scope: authData.scope, + require2fa: authData.require2fa + }; + + res.status(200); + res.json(permission.filter(preAuthResponse)); + return next(); + }) + ); + server.post( '/authenticate', tools.asyncifyJson(async (req, res, next) => { @@ -80,11 +170,13 @@ module.exports = (db, server, userHandler) => { } catch (err) { let response = { error: err.message, - code: 'AuthFailed' || err.code + code: err.code || 'AuthFailed' }; + if (user) { response.id = user.toString(); } + res.status(403); res.json(response); return next(); diff --git a/lib/user-handler.js b/lib/user-handler.js index 692a2aa2..fa66f6ea 100644 --- a/lib/user-handler.js +++ b/lib/user-handler.js @@ -1048,6 +1048,71 @@ class UserHandler { } } + async preAuth(username, requiredScope) { + requiredScope = requiredScope || 'master'; + + username = (username || '').toString(); + + let userQuery; + try { + userQuery = await this.checkAddress(username); + } catch (err) { + return [false, false]; + } + + if (!userQuery) { + // nothing to do here + return [false, false]; + } + + let userData; + try { + userData = await this.users.collection('users').findOne(userQuery, { + projection: { + _id: true, + username: true, + address: true, + enabled2fa: true, + webauthn: true, + disabled: true, + suspended: true, + disabledScopes: true + }, + maxTimeMS: consts.DB_MAX_TIME_USERS + }); + } catch (err) { + // return as failed auth + return [false, false]; + } + + if (!userData || userData.disabled || userData.suspended) { + // return as failed auth + return [false, false]; + } + + let disabledScopes = userData.disabledScopes || []; + if (disabledScopes.includes(requiredScope)) { + let err = new Error('Access to requested service disabled'); + err.response = 'NO'; + err.responseCode = 403; + err.code = 'InvalidAuthScope'; + err.user = userData._id; + throw err; + } + + let enabled2fa = tools.getEnabled2fa(userData.enabled2fa); + + let authResponse = { + user: userData._id, + username: userData.username, + scope: requiredScope, + // if 2FA is enabled then require token validation + require2fa: requiredScope === 'master' && enabled2fa.length ? enabled2fa : false + }; + + return [authResponse, userData._id]; + } + async generateASP(user, data) { let password = data.password || @@ -2646,7 +2711,7 @@ class UserHandler { .filter(credentialData => credentialData.authenticatorAttachment === data.authenticatorAttachment) .map(credentialData => ({ rawId: credentialData.rawId.toString('hex'), - type: credentialData.type, + type: credentialData.type })); // store chalenge diff --git a/package.json b/package.json index fbaa51da..4b3ff181 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@root/csr": "0.8.1", "accesscontrol": "2.2.1", "argon2-browser": "1.18.0", - "axios": "1.0.0", + "axios": "1.1.0", "base32.js": "0.1.0", "bcryptjs": "2.4.3", "bson": "4.7.0", diff --git a/test/api-test.js b/test/api-test.js index c6c638d2..517f2f6a 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -133,6 +133,30 @@ describe('API tests', function () { }); }); + describe('preauth', () => { + it('should POST /preauth with success', async () => { + const response = await server + .post(`/preauth`) + .send({ + username: 'testuser@example.com', + scope: 'master' + }) + .expect(200); + expect(response.body.success).to.be.true; + }); + + it('should POST /preauth using alias domain', async () => { + const response = await server + .post(`/preauth`) + .send({ + username: 'testuser@jõgeva.öö', + scope: 'master' + }) + .expect(200); + expect(response.body.success).to.be.true; + }); + }); + describe('asp', () => { it('should POST /users/:user/asps to generate ASP', async () => { const response = await server