Merge pull request #429 from nodemailer/zms-48

zms-48
This commit is contained in:
Andris Reinman 2022-10-07 10:22:14 +03:00 committed by GitHub
commit 32e6d3cc10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 283 additions and 14 deletions

View file

@ -296,7 +296,8 @@
},
"authentication": {
"read:own": ["*"]
"read:own": ["*"],
"create:own": ["*"]
},
"userlisting": {

View file

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

View file

@ -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();
})

View file

@ -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();

View file

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

View file

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

View file

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