From 0efae19f9e5d6368ec4121d7f4601df470ebba24 Mon Sep 17 00:00:00 2001 From: NickOvt Date: Mon, 26 Feb 2024 09:57:39 +0200 Subject: [PATCH] fix(api-2fa): Added 2FA API endpoints to API docs generation ZMS-124 (#626) * added Generate TOTP seed api endpoint to API generation * added Enable TOTP api endpoint to API docs generation * added Disable TOTP auth api endpoint to API docs generation * added Validate TOTP token api endpoint to API docs generation * added Disable 2FA api endpoint to API docs generation * Added Enable custom 2FA for a user api endpoint for API docs generation * Disable custom 2FA for a user endpoint added to API docs generation. Fix imports * added Get WebAuthN credentials for a user api endpoint to api docs generation * WebAuthN del and registration endpoints added to API docs generation * webAuthN authentication challenge and attestation endpoints added to API docs generation * fix rpId descriptions * add response objects to endpoints --- lib/api/2fa/custom.js | 54 +++++- lib/api/2fa/totp.js | 147 +++++++++++--- lib/api/2fa/webauthn.js | 413 +++++++++++++++++++++++++++++++--------- 3 files changed, 486 insertions(+), 128 deletions(-) diff --git a/lib/api/2fa/custom.js b/lib/api/2fa/custom.js index da45324f..a9c13a4e 100644 --- a/lib/api/2fa/custom.js +++ b/lib/api/2fa/custom.js @@ -5,20 +5,38 @@ const ObjectId = require('mongodb').ObjectId; const tools = require('../../tools'); const roles = require('../../roles'); const { sessSchema, sessIPSchema } = require('../../schemas'); +const { userId } = require('../../schemas/request/general-schemas'); +const { successRes } = require('../../schemas/response/general-schemas'); // Custom 2FA needs to be enabled if your website handles its own 2FA and you want to disable // master password usage for IMAP/POP/SMTP clients module.exports = (db, server, userHandler) => { server.put( - '/users/:user/2fa/custom', + { + path: '/users/:user/2fa/custom', + tags: ['TwoFactorAuth'], + summary: 'Enable custom 2FA for a user', + description: 'This method disables account password for IMAP/POP3/SMTP', + validationObjs: { + requestBody: { + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { 200: { description: 'Success', model: Joi.object({ success: successRes }) } } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -52,14 +70,30 @@ module.exports = (db, server, userHandler) => { ); server.del( - '/users/:user/2fa/custom', + { + path: '/users/:user/2fa/custom', + tags: ['TwoFactorAuth'], + summary: 'Disable custom 2FA for a user', + description: 'This method disables custom 2FA. If it was the only 2FA set up, then account password for IMAP/POP3/SMTP gets enabled again', + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { user: userId }, + response: { 200: { description: 'Success', model: Joi.object({ success: successRes }) } } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { diff --git a/lib/api/2fa/totp.js b/lib/api/2fa/totp.js index b71d87fa..38a2ee5f 100644 --- a/lib/api/2fa/totp.js +++ b/lib/api/2fa/totp.js @@ -5,20 +5,48 @@ const ObjectId = require('mongodb').ObjectId; const tools = require('../../tools'); const roles = require('../../roles'); const { sessSchema, sessIPSchema } = require('../../schemas'); +const { userId } = require('../../schemas/request/general-schemas'); +const { successRes } = require('../../schemas/response/general-schemas'); module.exports = (db, server, userHandler) => { // Create TOTP seed and request a QR code server.post( - '/users/:user/2fa/totp/setup', + { + path: '/users/:user/2fa/totp/setup', + tags: ['TwoFactorAuth'], + summary: 'Generate TOTP seed', + description: 'This method generates TOTP seed and QR code for 2FA. User needs to verify the seed value using 2fa/totp/enable endpoint', + validationObjs: { + requestBody: { + label: Joi.string().empty('').trim().max(255).description('Label text for QR code (defaults to username)'), + issuer: Joi.string().trim().max(255).required().description('Description text for QR code (defaults to "WildDuck")'), + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + seed: Joi.string().required().description('Generated TOTP seed value'), + qrcode: Joi.string().required().description('Base64 encoded QR code') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - label: Joi.string().empty('').trim().max(255), - issuer: Joi.string().trim().max(255).required(), - sess: sessSchema, - ip: sessIPSchema + + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -54,15 +82,31 @@ module.exports = (db, server, userHandler) => { ); server.post( - '/users/:user/2fa/totp/enable', + { + path: '/users/:user/2fa/totp/enable', + tags: ['TwoFactorAuth'], + summary: 'Enable TOTP seed', + description: 'This method enables TOTP for a user by verifying the seed value generated from 2fa/totp/setup', + validationObjs: { + requestBody: { + token: Joi.string().length(6).required().description('6-digit number that matches seed value from 2fa/totp/setup'), + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { 200: { description: 'Success', model: Joi.object({ success: successRes }) } } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - token: Joi.string().length(6).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -113,14 +157,27 @@ module.exports = (db, server, userHandler) => { ); server.del( - '/users/:user/2fa/totp', + { + path: '/users/:user/2fa/totp', + tags: ['TwoFactorAuth'], + summary: 'Disable TOTP auth', + description: 'This method disables TOTP for a user. Does not affect other 2FA mechanisms a user might have set up', + validationObjs: { + requestBody: {}, + queryParams: { sess: sessSchema, ip: sessIPSchema }, + pathParams: { user: userId }, + response: { 200: { description: 'Success', model: Joi.object({ success: successRes }) } } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -154,15 +211,31 @@ module.exports = (db, server, userHandler) => { ); server.post( - '/users/:user/2fa/totp/check', + { + path: '/users/:user/2fa/totp/check', + tags: ['TwoFactorAuth'], + summary: 'Validate TOTP Token', + description: 'This method checks if a TOTP token provided by a User is valid for authentication', + validationObjs: { + requestBody: { + token: Joi.string().length(6).required().description('6-digit number'), + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { 200: { description: 'Success', model: Joi.object({ success: successRes }) } } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - token: Joi.string().length(6).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -204,14 +277,30 @@ module.exports = (db, server, userHandler) => { ); server.del( - '/users/:user/2fa', + { + path: '/users/:user/2fa', + tags: ['TwoFactorAuth'], + summary: 'Disable 2FA', + description: 'This method disables all 2FA mechanisms a user might have set up', + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { user: userId }, + response: { 200: { description: 'Success', model: Joi.object({ success: successRes }) } } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { diff --git a/lib/api/2fa/webauthn.js b/lib/api/2fa/webauthn.js index 9dc33968..9cbbac1c 100644 --- a/lib/api/2fa/webauthn.js +++ b/lib/api/2fa/webauthn.js @@ -5,17 +5,58 @@ const ObjectId = require('mongodb').ObjectId; const tools = require('../../tools'); const roles = require('../../roles'); const { sessSchema, sessIPSchema, booleanSchema } = require('../../schemas'); +const { userId } = require('../../schemas/request/general-schemas'); +const { successRes } = require('../../schemas/response/general-schemas'); module.exports = (db, server, userHandler) => { server.get( - '/users/:user/2fa/webauthn/credentials', + { + path: '/users/:user/2fa/webauthn/credentials', + tags: ['TwoFactorAuth'], + summary: 'Get WebAuthN credentials for a user', + description: 'This method returns the list of WebAuthN credentials for a given user', + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { user: userId }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + credentials: Joi.array() + .items( + Joi.object({ + id: Joi.string().required().description('Credential ID'), + rawId: Joi.string().hex().required().description('Raw ID string of the credential in hex'), + description: Joi.string().required().description('Descriptive name for the authenticator'), + authenticatorAttachment: Joi.string() + .required() + .description( + 'Indicates whether authenticators is a part of the OS ("platform"), or roaming authenticators ("cross-platform")' + ) + .example('platform') + }) + ) + .required() + .description('List of credentials') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -70,15 +111,41 @@ module.exports = (db, server, userHandler) => { ); server.del( - '/users/:user/2fa/webauthn/credentials/:credential', + { + path: '/users/:user/2fa/webauthn/credentials/:credential', + tags: ['TwoFactorAuth'], + summary: 'Remove WebAuthN authenticator', + description: 'This method deletes the given WebAuthN authenticator for given user.', + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + user: userId, + credential: Joi.string().hex().lowercase().length(24).required().description('Credential ID') + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + deleted: booleanSchema.required().description('Specifies whether the given credential has been deleted') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - credential: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -116,24 +183,84 @@ module.exports = (db, server, userHandler) => { // Get webauthn challenge server.post( - '/users/:user/2fa/webauthn/registration-challenge', + { + path: '/users/:user/2fa/webauthn/registration-challenge', + tags: ['TwoFactorAuth'], + summary: 'Get the WebAuthN registration challenge', + description: 'This method initiates the WebAuthN authenticator registration challenge', + validationObjs: { + requestBody: { + description: Joi.string().empty('').max(1024).required().description('Descriptive name for the authenticator'), + origin: Joi.string().empty('').uri().required().description('Origin'), + + authenticatorAttachment: Joi.string() + .valid('platform', 'cross-platform') + .example('cross-platform') + .default('cross-platform') + .description( + 'Indicates whether authenticators should be part of the OS ("platform"), or can be roaming authenticators ("cross-platform")' + ), + + rpId: Joi.string().hostname().empty('').description('Relaying party ID. Is domain.'), + + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + registrationOptions: Joi.object({ + challenge: Joi.string().hex().required().description('Challenge as a hex string'), + user: Joi.object({ + id: userId, + name: Joi.string().required().description('User address or name'), + displayName: Joi.string().required().description('User display name or username') + }), + authenticatorSelection: Joi.object({ + authenticatorAttachment: Joi.string().required().description('"platform" or "cross-platform"') + }) + .required() + .description('Data about the authenticator'), + rp: Joi.object({ + name: Joi.string().required().description('Rp name'), + id: Joi.string().required().description('Rp ID. Domain'), + icon: Joi.string().description('Rp icon. data/image string in base64 format') + }) + .required() + .description('Relaying party data'), + excludeCredentials: Joi.array() + .items( + Joi.object({ + rawId: Joi.string().description('Raw ID of the credential as hex string').required(), + type: Joi.string().required().description('Type of the credential'), + transports: Joi.array() + .items(Joi.string().required()) + .required() + .description( + 'Credential transports. If authenticatorAttachment is "platform" then ["internal"] otherwise ["usb", "nfc", "ble"]' + ) + }) + ) + .description('List of credentials to exclude') + }) + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - description: Joi.string().empty('').max(1024).required().description('Descriptive name for the authenticator'), - origin: Joi.string().empty('').uri().required(), - authenticatorAttachment: Joi.string() - .valid('platform', 'cross-platform') - .example('cross-platform') - .default('cross-platform') - .description('Indicates whether authenticators should be part of the OS ("platform"), or can be roaming authenticators ("cross-platform")'), + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; - rpId: Joi.string().hostname().empty(''), - - sess: sessSchema, - ip: sessIPSchema + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -168,29 +295,58 @@ module.exports = (db, server, userHandler) => { ); server.post( - '/users/:user/2fa/webauthn/registration-attestation', + { + path: '/users/:user/2fa/webauthn/registration-attestation', + tags: ['TwoFactorAuth'], + summary: 'Attestate WebAuthN authenticator', + description: 'Attestation is used to verify the authenticity of the authenticator and provide assurances about its features.', + validationObjs: { + requestBody: { + challenge: Joi.string().empty('').hex().max(2048).required().description('Challenge as hex string'), + rawId: Joi.string().empty('').hex().max(2048).required().description('Credential ID/RawID as hex string'), + clientDataJSON: Joi.string() + .empty('') + .hex() + .max(1024 * 1024) + .required() + .description('Clientside data JSON as hex string'), + attestationObject: Joi.string() + .empty('') + .hex() + .max(1024 * 1024) + .required() + .description('Attestation object represented as a hex string'), + + rpId: Joi.string().hostname().empty('').description('Relaying party ID. Is domain.'), + + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + id: Joi.string().required().description('Credential ID'), + rawId: Joi.string().hex().required().description('Credential RawID as a hex string'), + description: Joi.string().required().description('Description for the authenticator'), + authenticatorAttachment: Joi.string().required().description('Specifies whether authenticator is "platform" or "cross-platform"') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - - challenge: Joi.string().empty('').hex().max(2048).required(), - rawId: Joi.string().empty('').hex().max(2048).required(), - clientDataJSON: Joi.string() - .empty('') - .hex() - .max(1024 * 1024) - .required(), - attestationObject: Joi.string() - .empty('') - .hex() - .max(1024 * 1024) - .required(), - - rpId: Joi.string().hostname().empty(''), - - sess: sessSchema, - ip: sessIPSchema + + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -227,22 +383,68 @@ module.exports = (db, server, userHandler) => { // Get webauthn challenge server.post( - '/users/:user/2fa/webauthn/authentication-challenge', + { + path: '/users/:user/2fa/webauthn/authentication-challenge', + tags: ['TwoFactorAuth'], + summary: 'Begin WebAuthN authentication challenge', + description: 'This method retrieves the WebAuthN PublicKeyCredentialRequestOptions object to use it for authentication', + validationObjs: { + requestBody: { + origin: Joi.string().empty('').uri().required().description('Origin domain'), + authenticatorAttachment: Joi.string() + .valid('platform', 'cross-platform') + .example('cross-platform') + .default('cross-platform') + .description( + 'Indicates whether authenticators should be part of the OS ("platform"), or can be roaming authenticators ("cross-platform")' + ), + + rpId: Joi.string().hostname().empty('').description('Relaying party ID. Domain'), + + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + authenticationOptions: Joi.object({ + challenge: Joi.string().hex().required().description('Challenge as hex string'), + allowCredentials: Joi.array() + .items( + Joi.object({ + rawId: Joi.string().hex().required().description('RawId of the credential as hex string'), + type: Joi.string().required().description('Credential type') + }) + ) + .required() + .description('Allowed credential(s) based on the request'), + rpId: Joi.string().description('Relaying Party ID. Domain'), + rawChallenge: Joi.string().description('Raw challenge bytes. ArrayBuffer'), + attestation: Joi.string().description('Attestation string. `direct`/`indirect`/`none`'), + extensions: Joi.object({}).description('Any credential extensions'), + userVerification: Joi.string().description('User verification type. `required`/`preferred`/`discouraged`'), + timeout: Joi.number().description('Timeout in milliseconds (ms)') + }) + .required() + .description('PublicKeyCredentialRequestOptions object') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - origin: Joi.string().empty('').uri().required(), - authenticatorAttachment: Joi.string() - .valid('platform', 'cross-platform') - .example('cross-platform') - .default('cross-platform') - .description('Indicates whether authenticators should be part of the OS ("platform"), or can be roaming authenticators ("cross-platform")'), - - rpId: Joi.string().hostname().empty(''), - - sess: sessSchema, - ip: sessIPSchema + + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -277,37 +479,70 @@ module.exports = (db, server, userHandler) => { ); server.post( - '/users/:user/2fa/webauthn/authentication-assertion', + { + path: '/users/:user/2fa/webauthn/authentication-assertion', + tags: ['TwoFactorAuth'], + summary: 'WebAuthN authentication Assertion', + description: 'Assert WebAuthN authentication request and actually authenticate the user', + validationObjs: { + requestBody: { + challenge: Joi.string().empty('').hex().max(2048).required().description('Challenge of the credential as hex string'), + rawId: Joi.string().empty('').hex().max(2048).required().description('RawId of the credential'), + clientDataJSON: Joi.string() + .empty('') + .hex() + .max(1024 * 1024) + .required() + .description('Client data JSON as hex string'), + authenticatorData: Joi.string() + .empty('') + .hex() + .max(1024 * 1024) + .required() + .description('Authentication data as hex string'), + + signature: Joi.string() + .empty('') + .hex() + .max(1024 * 1024) + .required() + .description('Private key encrypted signature to verify with public key on the server. Hex string'), + + rpId: Joi.string().hostname().empty('').description('Relaying party ID. Domain'), + + token: booleanSchema.default(false).description('If true response will contain the user auth token'), + + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { user: userId }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + response: Joi.object({ + authenticated: booleanSchema.required().description('Authentication status'), + credential: Joi.string().required().description('WebAuthN credential ID') + }) + .required() + .description('Auth data'), + token: Joi.string().description('User auth token') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - - challenge: Joi.string().empty('').hex().max(2048).required(), - rawId: Joi.string().empty('').hex().max(2048).required(), - clientDataJSON: Joi.string() - .empty('') - .hex() - .max(1024 * 1024) - .required(), - authenticatorData: Joi.string() - .empty('') - .hex() - .max(1024 * 1024) - .required(), - - signature: Joi.string() - .empty('') - .hex() - .max(1024 * 1024) - .required(), - - rpId: Joi.string().hostname().empty(''), - - token: booleanSchema.default(false), - - sess: sessSchema, - ip: sessIPSchema + + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, {