From fe8066c8f40883032f26192d41e61dee4720ca1f Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 1 Oct 2023 07:33:10 +0000 Subject: [PATCH] feat: setup TFA --- server/app/data.yml | 4 + server/core/auth.mjs | 8 +- server/db/migrations/3.0.0.mjs | 17 +- server/graph/resolvers/authentication.mjs | 18 +- server/graph/schemas/authentication.graphql | 36 +-- server/locales/en.json | 15 +- server/models/userKeys.mjs | 1 + server/models/users.mjs | 249 +++++++--------- .../authentication/local/authentication.mjs | 6 +- .../authentication/local/definition.yml | 14 +- .../_assets/icons/ultraviolet-pin-pad.svg | 1 + ux/src/components/AuthLoginPanel.vue | 273 +++++++++++++----- ux/src/pages/AdminAuth.vue | 14 +- 13 files changed, 383 insertions(+), 273 deletions(-) create mode 100644 ux/public/_assets/icons/ultraviolet-pin-pad.svg diff --git a/server/app/data.yml b/server/app/data.yml index 535ed24f..4d0ef92b 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -133,6 +133,10 @@ editors: wysiwyg: contentType: html config: {} +systemIds: + localAuthId: '5a528c4c-0a82-4ad2-96a5-2b23811e6588' + guestsGroupId: '10000000-0000-4000-8000-000000000001' + usersGroupId: '20000000-0000-4000-8000-000000000002' groups: defaultPermissions: - 'read:pages' diff --git a/server/core/auth.mjs b/server/core/auth.mjs index a5dac737..d889d21f 100644 --- a/server/core/auth.mjs +++ b/server/core/auth.mjs @@ -79,13 +79,9 @@ export default { for (const stg of enabledStrategies) { try { const strategy = (await import(`../modules/authentication/${stg.module}/authentication.mjs`)).default + strategy.init(passport, stg.id, stg.config) - stg.config.callbackURL = `${WIKI.config.host}/login/${stg.id}/callback` - stg.config.key = stg.id - strategy.init(passport, stg.config) - strategy.config = stg.config - - WIKI.auth.strategies[stg.key] = { + WIKI.auth.strategies[stg.id] = { ...strategy, ...stg } diff --git a/server/db/migrations/3.0.0.mjs b/server/db/migrations/3.0.0.mjs index 406f8514..9886c145 100644 --- a/server/db/migrations/3.0.0.mjs +++ b/server/db/migrations/3.0.0.mjs @@ -66,8 +66,8 @@ export async function up (knex) { table.boolean('isEnabled').notNullable().defaultTo(false) table.string('displayName').notNullable().defaultTo('') table.jsonb('config').notNullable().defaultTo('{}') - table.boolean('selfRegistration').notNullable().defaultTo(false) - table.string('allowedEmailRegex') + table.boolean('registration').notNullable().defaultTo(false) + table.string('allowedEmailRegex').notNullable().defaultTo('') table.specificType('autoEnrollGroups', 'uuid[]') }) .createTable('blocks', table => { @@ -430,10 +430,10 @@ export async function up (knex) { // -> GENERATE IDS const groupAdminId = uuid() - const groupUserId = uuid() - const groupGuestId = '10000000-0000-4000-8000-000000000001' + const groupUserId = WIKI.data.systemIds.usersGroupId + const groupGuestId = WIKI.data.systemIds.guestsGroupId const siteId = uuid() - const authModuleId = uuid() + const authModuleId = WIKI.data.systemIds.localAuthId const userAdminId = uuid() const userGuestId = uuid() @@ -719,7 +719,11 @@ export async function up (knex) { id: authModuleId, module: 'local', isEnabled: true, - displayName: 'Local Authentication' + displayName: 'Local Authentication', + config: JSON.stringify({ + emailValidation: true, + enforceTfa: false + }) }) // -> USERS @@ -734,6 +738,7 @@ export async function up (knex) { mustChangePwd: false, // TODO: Revert to true (below) once change password flow is implemented // mustChangePwd: !process.env.ADMIN_PASS, restrictLogin: false, + tfaIsActive: false, tfaRequired: false, tfaSecret: '' } diff --git a/server/graph/resolvers/authentication.mjs b/server/graph/resolvers/authentication.mjs index 82d1c4c4..90cf3c27 100644 --- a/server/graph/resolvers/authentication.mjs +++ b/server/graph/resolvers/authentication.mjs @@ -46,7 +46,7 @@ export default { return { ...a, config: _.transform(str.props, (r, v, k) => { - r[k] = v.sensitive ? a.config[k] : '********' + r[k] = v.sensitive ? '********' : a.config[k] }, {}) } }) @@ -102,7 +102,7 @@ export default { if (args.strategy === 'ldap' && WIKI.config.flags.ldapdebug) { WIKI.logger.warn('LDAP LOGIN ERROR (c1): ', err) } - console.error(err) + WIKI.logger.debug(err) return generateError(err) } @@ -115,9 +115,10 @@ export default { const authResult = await WIKI.db.users.loginTFA(args, context) return { ...authResult, - responseResult: generateSuccess('TFA success') + operation: generateSuccess('TFA success') } } catch (err) { + WIKI.logger.debug(err) return generateError(err) } }, @@ -129,9 +130,10 @@ export default { const authResult = await WIKI.db.users.loginChangePassword(args, context) return { ...authResult, - responseResult: generateSuccess('Password changed successfully') + operation: generateSuccess('Password changed successfully') } } catch (err) { + WIKI.logger.debug(err) return generateError(err) } }, @@ -142,7 +144,7 @@ export default { try { await WIKI.db.users.loginForgotPassword(args, context) return { - responseResult: generateSuccess('Password reset request processed.') + operation: generateSuccess('Password reset request processed.') } } catch (err) { return generateError(err) @@ -153,9 +155,11 @@ export default { */ async register (obj, args, context) { try { - await WIKI.db.users.register({ ...args, verify: true }, context) + const usr = await WIKI.db.users.createNewUser({ ...args, userInitiated: true }) + const authResult = await WIKI.db.users.afterLoginChecks(usr, WIKI.data.systemIds.localAuthId, context) return { - responseResult: generateSuccess('Registration success') + ...authResult, + operation: generateSuccess('Registration success') } } catch (err) { return generateError(err) diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index 43049e71..79ae2c9d 100644 --- a/server/graph/schemas/authentication.graphql +++ b/server/graph/schemas/authentication.graphql @@ -30,14 +30,16 @@ extend type Mutation { username: String! password: String! strategyId: UUID! - siteId: UUID - ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60) + siteId: UUID! + ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60) loginTFA( continuationToken: String! securityCode: String! + strategyId: UUID! + siteId: UUID! setup: Boolean - ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60) + ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60) changePassword( userId: UUID @@ -46,7 +48,7 @@ extend type Mutation { newPassword: String! strategyId: UUID! siteId: UUID - ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60) + ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60) forgotPassword( email: String! @@ -56,7 +58,7 @@ extend type Mutation { email: String! password: String! name: String! - ): AuthenticationRegisterResponse + ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60) refreshToken( token: String! @@ -105,7 +107,7 @@ type AuthenticationActiveStrategy { displayName: String isEnabled: Boolean config: JSON - selfRegistration: Boolean + registration: Boolean allowedEmailRegex: String autoEnrollGroups: [UUID] } @@ -116,22 +118,15 @@ type AuthenticationSiteStrategy { isVisible: Boolean } -type AuthenticationLoginResponse { +type AuthenticationAuthResponse { operation: Operation jwt: String - mustChangePwd: Boolean - mustProvideTFA: Boolean - mustSetupTFA: Boolean + nextAction: AuthenticationNextAction continuationToken: String redirect: String tfaQRImage: String } -type AuthenticationRegisterResponse { - operation: Operation - jwt: String -} - type AuthenticationTokenResponse { operation: Operation jwt: String @@ -140,11 +135,11 @@ type AuthenticationTokenResponse { input AuthenticationStrategyInput { key: String! strategyKey: String! - config: [KeyValuePairInput] + config: JSON! displayName: String! order: Int! isEnabled: Boolean! - selfRegistration: Boolean! + registration: Boolean! allowedEmailRegex: String! autoEnrollGroups: [UUID]! } @@ -163,3 +158,10 @@ type AuthenticationCreateApiKeyResponse { operation: Operation key: String } + +enum AuthenticationNextAction { + changePassword + setupTfa + provideTfa + redirect +} diff --git a/server/locales/en.json b/server/locales/en.json index c5800a5c..f25da081 100644 --- a/server/locales/en.json +++ b/server/locales/en.json @@ -76,11 +76,13 @@ "admin.auth.configReferenceSubtitle": "Some strategies may require some configuration values to be set on your provider. These are provided for reference only and may not be needed by the current strategy.", "admin.auth.displayName": "Display Name", "admin.auth.displayNameHint": "The title shown to the end user for this authentication strategy.", + "admin.auth.emailValidation": "Email Validation", + "admin.auth.emailValidationHint": "Send a verification email to the user with a validation link when registering.", "admin.auth.enabled": "Enabled", "admin.auth.enabledForced": "This strategy cannot be disabled.", "admin.auth.enabledHint": "Should this strategy be available to sites for login.", - "admin.auth.force2fa": "Force all users to use Two-Factor Authentication (2FA)", - "admin.auth.force2faHint": "Users will be required to setup 2FA the first time they login and cannot be disabled by the user.", + "admin.auth.enforceTfa": "Enforce Two-Factor Authentication", + "admin.auth.enforceTfaHint": "Users will be required to setup 2FA the first time they login and cannot be disabled by the user.", "admin.auth.globalAdvSettings": "Global Advanced Settings", "admin.auth.info": "Info", "admin.auth.infoName": "Name", @@ -90,10 +92,10 @@ "admin.auth.noConfigOption": "This strategy has no configuration options you can modify.", "admin.auth.refreshSuccess": "List of strategies has been refreshed.", "admin.auth.registration": "Registration", + "admin.auth.registrationHint": "Allow any user successfully authorized by the strategy to access the wiki.", + "admin.auth.registrationLocalHint": "Whether to allow guests to register new accounts.", "admin.auth.saveSuccess": "Authentication configuration saved successfully.", "admin.auth.security": "Security", - "admin.auth.selfRegistration": "Allow Self-Registration", - "admin.auth.selfRegistrationHint": "Allow any user successfully authorized by the strategy to access the wiki.", "admin.auth.siteUrlNotSetup": "You must set a valid {siteUrl} first! Click on {general} in the left sidebar.", "admin.auth.status": "Status", "admin.auth.strategies": "Strategies", @@ -1192,9 +1194,10 @@ "auth.tfa.subtitle": "Security code required:", "auth.tfa.verifyToken": "Verify", "auth.tfaFormTitle": "Enter the security code generated from your trusted device:", - "auth.tfaSetupInstrFirst": "1) Scan the QR code below from your mobile 2FA application:", - "auth.tfaSetupInstrSecond": "2) Enter the security code generated from your trusted device:", + "auth.tfaSetupInstrFirst": "Scan the QR code below from your mobile 2FA application:", + "auth.tfaSetupInstrSecond": "Enter the security code generated from your trusted device:", "auth.tfaSetupTitle": "Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.", + "auth.tfaSetupVerifying": "Verifying...", "common.actions.activate": "Activate", "common.actions.add": "Add", "common.actions.apply": "Apply", diff --git a/server/models/userKeys.mjs b/server/models/userKeys.mjs index c47952d3..f2dee07b 100644 --- a/server/models/userKeys.mjs +++ b/server/models/userKeys.mjs @@ -47,6 +47,7 @@ export class UserKey extends Model { } static async generateToken ({ userId, kind, meta }, context) { + WIKI.logger.debug(`Generating ${kind} token for user ${userId}...`) const token = await nanoid() await WIKI.db.userKeys.query().insert({ kind, diff --git a/server/models/users.mjs b/server/models/users.mjs index d63f71c5..d889c3d8 100644 --- a/server/models/users.mjs +++ b/server/models/users.mjs @@ -9,7 +9,6 @@ import qr from 'qr-image' import bcrypt from 'bcryptjs' import { Group } from './groups.mjs' -import { Locale } from './locales.mjs' /** * Users model @@ -73,35 +72,39 @@ export class User extends Model { // Instance Methods // ------------------------------------------------ - async generateTFA() { - let tfaInfo = tfa.generateSecret({ - name: WIKI.config.title, + async generateTFA(strategyId, siteId) { + WIKI.logger.debug(`Generating new TFA secret for user ${this.id}...`) + const site = WIKI.sites[siteId] ?? WIKI.sites[0] ?? { config: { title: 'Wiki' }} + const tfaInfo = tfa.generateSecret({ + name: site.config.title, account: this.email }) - await WIKI.db.users.query().findById(this.id).patch({ - tfaIsActive: false, - tfaSecret: tfaInfo.secret + this.auth[strategyId].tfaSecret = tfaInfo.secret + this.auth[strategyId].tfaIsActive = false + await this.$query().patch({ + auth: this.auth }) - const safeTitle = WIKI.config.title.replace(/[\s-.,=!@#$%?&*()+[\]{}/\\;<>]/g, '') + const safeTitle = site.config.title.replace(/[\s-.,=!@#$%?&*()+[\]{}/\\;<>]/g, '') return qr.imageSync(`otpauth://totp/${safeTitle}:${this.email}?secret=${tfaInfo.secret}`, { type: 'svg' }) } - async enableTFA() { - return WIKI.db.users.query().findById(this.id).patch({ - tfaIsActive: true + async enableTFA(strategyId) { + this.auth[strategyId].tfaIsActive = true + return this.$query().patch({ + auth: this.auth }) } - async disableTFA() { - return this.$query.patch({ + async disableTFA(strategyId) { + this.auth[strategyId].tfaIsActive = false + return this.$query().patch({ tfaIsActive: false, tfaSecret: '' }) } - verifyTFA(code) { - let result = tfa.verifyToken(this.tfaSecret, code) - return (result && has(result, 'delta') && result.delta === 0) + verifyTFA(strategyId, code) { + return tfa.verifyToken(this.auth[strategyId].tfaSecret, code)?.delta === 0 } getPermissions () { @@ -250,9 +253,9 @@ export class User extends Model { /** * Login a user */ - static async login (opts, context) { - if (has(WIKI.auth.strategies, opts.strategy)) { - const selStrategy = get(WIKI.auth.strategies, opts.strategy) + static async login ({ strategyId, siteId, username, password }, context) { + if (has(WIKI.auth.strategies, strategyId)) { + const selStrategy = WIKI.auth.strategies[strategyId] if (!selStrategy.isEnabled) { throw new WIKI.Error.AuthProviderInvalid() } @@ -261,9 +264,9 @@ export class User extends Model { // Inject form user/pass if (strInfo.useForm) { - set(context.req, 'body.email', opts.username) - set(context.req, 'body.password', opts.password) - set(context.req.params, 'strategy', opts.strategy) + set(context.req, 'body.email', username) + set(context.req, 'body.password', password) + set(context.req.params, 'strategy', strategyId) } // Authenticate @@ -277,6 +280,7 @@ export class User extends Model { try { const resp = await WIKI.db.users.afterLoginChecks(user, selStrategy.id, context, { + siteId, skipTFA: !strInfo.useForm, skipChangePwd: !strInfo.useForm }) @@ -294,7 +298,12 @@ export class User extends Model { /** * Perform post-login checks */ - static async afterLoginChecks (user, strategyId, context, { skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false }) { + static async afterLoginChecks (user, strategyId, context, { siteId, skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false, siteId: null }) { + const str = WIKI.auth.strategies[strategyId] + if (!str) { + throw new Error('ERR_INVALID_STRATEGY') + } + // Get redirect target user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions', 'redirectOnLogin') let redirect = '/' @@ -312,14 +321,14 @@ export class User extends Model { // Is 2FA required? if (!skipTFA) { - if (authStr.tfaRequired && authStr.tfaSecret) { + if (authStr.tfaIsActive && authStr.tfaSecret) { try { const tfaToken = await WIKI.db.userKeys.generateToken({ kind: 'tfa', userId: user.id }) return { - mustProvideTFA: true, + nextAction: 'provideTfa', continuationToken: tfaToken, redirect } @@ -327,15 +336,15 @@ export class User extends Model { WIKI.logger.warn(errc) throw new WIKI.Error.AuthGenericError() } - } else if (WIKI.config.auth.enforce2FA || (authStr.tfaIsActive && !authStr.tfaSecret)) { + } else if (str.config?.enforceTfa || authStr.tfaRequired) { try { - const tfaQRImage = await user.generateTFA() + const tfaQRImage = await user.generateTFA(strategyId, siteId) const tfaToken = await WIKI.db.userKeys.generateToken({ kind: 'tfaSetup', userId: user.id }) return { - mustSetupTFA: true, + nextAction: 'setupTfa', continuationToken: tfaToken, tfaQRImage, redirect @@ -356,7 +365,7 @@ export class User extends Model { }) return { - mustChangePwd: true, + nextAction: 'changePassword', continuationToken: pwdChangeToken, redirect } @@ -370,7 +379,11 @@ export class User extends Model { context.req.login(user, { session: false }, async errc => { if (errc) { return reject(errc) } const jwtToken = await WIKI.db.users.refreshToken(user, strategyId) - resolve({ jwt: jwtToken.token, redirect }) + resolve({ + nextAction: 'redirect', + jwt: jwtToken.token, + redirect + }) }) }) } @@ -420,19 +433,19 @@ export class User extends Model { /** * Verify a TFA login */ - static async loginTFA ({ securityCode, continuationToken, setup }, context) { + static async loginTFA ({ strategyId, siteId, securityCode, continuationToken, setup }, context) { if (securityCode.length === 6 && continuationToken.length > 1) { - const user = await WIKI.db.userKeys.validateToken({ + const { user } = await WIKI.db.userKeys.validateToken({ kind: setup ? 'tfaSetup' : 'tfa', token: continuationToken, skipDelete: setup }) if (user) { - if (user.verifyTFA(securityCode)) { + if (user.verifyTFA(strategyId, securityCode)) { if (setup) { - await user.enableTFA() + await user.enableTFA(strategyId) } - return WIKI.db.users.afterLoginChecks(user, context, { skipTFA: true }) + return WIKI.db.users.afterLoginChecks(user, strategyId, context, { siteId, skipTFA: true }) } else { throw new WIKI.Error.AuthTFAFailed() } @@ -508,7 +521,14 @@ export class User extends Model { * * @param {Object} param0 User Fields */ - static async createNewUser ({ email, password, name, groups, mustChangePassword = false, sendWelcomeEmail = false }) { + static async createNewUser ({ email, password, name, groups, userInitiated = false, mustChangePassword = false, sendWelcomeEmail = false }) { + const localAuth = await WIKI.db.authentication.getStrategy('local') + + // Check if self-registration is enabled + if (userInitiated && !localAuth.registration) { + throw new Error('ERR_REGISTRATION_DISABLED') + } + // Input sanitization email = email.toLowerCase().trim() @@ -547,14 +567,23 @@ export class User extends Model { throw new Error(`ERR_INVALID_INPUT: ${validation[0]}`) } + // Check if email address is allowed + if (userInitiated && localAuth.allowedEmailRegex) { + const emailCheckRgx = new RegExp(localAuth.allowedEmailRegex, 'i') + if (!emailCheckRgx.test(email)) { + throw new Error('ERR_EMAIL_ADDRESS_NOT_ALLOWED') + } + } + // Check if email already exists const usr = await WIKI.db.users.query().findOne({ email }) if (usr) { throw new Error('ERR_ACCOUNT_ALREADY_EXIST') } + WIKI.logger.debug(`Creating new user account for ${email}...`) + // Create the account - const localAuth = await WIKI.db.authentication.getStrategy('local') const newUsr = await WIKI.db.users.query().insert({ email, name, @@ -583,14 +612,41 @@ export class User extends Model { dateFormat: WIKI.config.userDefaults.dateFormat || 'YYYY-MM-DD', timeFormat: WIKI.config.userDefaults.timeFormat || '12h' } - }) + }).returning('*') // Assign to group(s) - if (groups.length > 0) { - await newUsr.$relatedQuery('groups').relate(groups) + const groupsToEnroll = [WIKI.data.systemIds.usersGroupId] + if (groups?.length > 0) { + groupsToEnroll.push(...groups) + } + if (userInitiated && localAuth.autoEnrollGroups?.length > 0) { + groupsToEnroll.push(...localAuth.autoEnrollGroups) } + await newUsr.$relatedQuery('groups').relate(uniq(groupsToEnroll)) + + // Verification Email + if (userInitiated && localAuth.config?.emailValidation) { + // Create verification token + const verificationToken = await WIKI.db.userKeys.generateToken({ + kind: 'verify', + userId: newUsr.id + }) - if (sendWelcomeEmail) { + // Send verification email + await WIKI.mail.send({ + template: 'accountVerify', + to: email, + subject: 'Verify your account', + data: { + preheadertext: 'Verify your account in order to gain access to the wiki.', + title: 'Verify your account', + content: 'Click the button below in order to verify your account and gain access to the wiki.', + buttonLink: `${WIKI.config.host}/verify/${verificationToken}`, + buttonText: 'Verify' + }, + text: `You must open the following link in your browser to verify your account and gain access to the wiki: ${WIKI.config.host}/verify/${verificationToken}` + }) + } else if (sendWelcomeEmail) { // Send welcome email await WIKI.mail.send({ template: 'accountWelcome', @@ -606,6 +662,10 @@ export class User extends Model { text: `You've been invited to the wiki ${WIKI.config.title}: ${WIKI.config.host}/login` }) } + + WIKI.logger.debug(`Created new user account for ${email} successfully.`) + + return newUsr } /** @@ -680,113 +740,6 @@ export class User extends Model { } } - /** - * Register a new user (client-side registration) - * - * @param {Object} param0 User fields - * @param {Object} context GraphQL Context - */ - static async register ({ email, password, name, verify = false, bypassChecks = false }, context) { - const localStrg = await WIKI.db.authentication.getStrategy('local') - // Check if self-registration is enabled - if (localStrg.selfRegistration || bypassChecks) { - // Input sanitization - email = email.toLowerCase() - - // Input validation - const validation = validate({ - email, - password, - name - }, { - email: { - email: true, - length: { - maximum: 255 - } - }, - password: { - presence: { - allowEmpty: false - }, - length: { - minimum: 6 - } - }, - name: { - presence: { - allowEmpty: false - }, - length: { - minimum: 2, - maximum: 255 - } - } - }, { format: 'flat' }) - if (validation && validation.length > 0) { - throw new WIKI.Error.InputInvalid(validation[0]) - } - - // Check if email domain is whitelisted - if (get(localStrg, 'domainWhitelist.v', []).length > 0 && !bypassChecks) { - const emailDomain = last(email.split('@')) - if (!localStrg.domainWhitelist.v.includes(emailDomain)) { - throw new WIKI.Error.AuthRegistrationDomainUnauthorized() - } - } - // Check if email already exists - const usr = await WIKI.db.users.query().findOne({ email, providerKey: 'local' }) - if (!usr) { - // Create the account - const newUsr = await WIKI.db.users.query().insert({ - provider: 'local', - email, - name, - password, - locale: 'en', - defaultEditor: 'markdown', - tfaIsActive: false, - isSystem: false, - isActive: true, - isVerified: false - }) - - // Assign to group(s) - if (get(localStrg, 'autoEnrollGroups.v', []).length > 0) { - await newUsr.$relatedQuery('groups').relate(localStrg.autoEnrollGroups.v) - } - - if (verify) { - // Create verification token - const verificationToken = await WIKI.db.userKeys.generateToken({ - kind: 'verify', - userId: newUsr.id - }) - - // Send verification email - await WIKI.mail.send({ - template: 'accountVerify', - to: email, - subject: 'Verify your account', - data: { - preheadertext: 'Verify your account in order to gain access to the wiki.', - title: 'Verify your account', - content: 'Click the button below in order to verify your account and gain access to the wiki.', - buttonLink: `${WIKI.config.host}/verify/${verificationToken}`, - buttonText: 'Verify' - }, - text: `You must open the following link in your browser to verify your account and gain access to the wiki: ${WIKI.config.host}/verify/${verificationToken}` - }) - } - return true - } else { - throw new WIKI.Error.AuthAccountAlreadyExists() - } - } else { - throw new WIKI.Error.AuthRegistrationDisabled() - } - } - /** * Logout the current user */ diff --git a/server/modules/authentication/local/authentication.mjs b/server/modules/authentication/local/authentication.mjs index 3d3355fe..9394b648 100644 --- a/server/modules/authentication/local/authentication.mjs +++ b/server/modules/authentication/local/authentication.mjs @@ -8,8 +8,8 @@ import bcrypt from 'bcryptjs' import { Strategy } from 'passport-local' export default { - init (passport, conf) { - passport.use(conf.key, + init (passport, strategyId, conf) { + passport.use(strategyId, new Strategy({ usernameField: 'email', passwordField: 'password' @@ -19,7 +19,7 @@ export default { email: uEmail.toLowerCase() }) if (user) { - const authStrategyData = user.auth[conf.key] + const authStrategyData = user.auth[strategyId] if (!authStrategyData) { throw new WIKI.Error.AuthLoginFailed() } else if (await bcrypt.compare(uPassword, authStrategyData.password) !== true) { diff --git a/server/modules/authentication/local/definition.yml b/server/modules/authentication/local/definition.yml index 7efaffd3..77478da2 100644 --- a/server/modules/authentication/local/definition.yml +++ b/server/modules/authentication/local/definition.yml @@ -10,4 +10,16 @@ website: 'https://js.wiki' isAvailable: true useForm: true usernameType: email -props: {} +props: + enforceTfa: + type: Boolean + title: Enforce Two-Factor Authentication + hint: Users will be required to setup 2FA the first time they login and cannot be disabled by the user. + icon: pin-pad + default: false + emailValidation: + type: Boolean + title: Email Validation + hint: Send a verification email to the user with a validation link when registering (if registration is enabled). + icon: received + default: true diff --git a/ux/public/_assets/icons/ultraviolet-pin-pad.svg b/ux/public/_assets/icons/ultraviolet-pin-pad.svg new file mode 100644 index 00000000..85a41089 --- /dev/null +++ b/ux/public/_assets/icons/ultraviolet-pin-pad.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/src/components/AuthLoginPanel.vue b/ux/src/components/AuthLoginPanel.vue index eaa26352..64a455f8 100644 --- a/ux/src/components/AuthLoginPanel.vue +++ b/ux/src/components/AuthLoginPanel.vue @@ -53,7 +53,8 @@ ) template(v-if='selectedStrategy.activeStrategy?.strategy?.key === `local`') q-separator.q-my-md - q-btn.acrylic-btn.full-width( + q-btn.acrylic-btn.full-width.q-mb-sm( + v-if='selectedStrategy.activeStrategy.registration' flat color='primary' :label='t(`auth.switchToRegister.link`)' @@ -61,7 +62,7 @@ icon='las la-user-plus' @click='switchTo(`register`)' ) - q-btn.acrylic-btn.full-width.q-mt-sm( + q-btn.acrylic-btn.full-width( flat color='primary' :label='t(`auth.forgotPasswordLink`)' @@ -248,17 +249,15 @@ //- ----------------------------------------------------- template(v-else-if='state.screen === `tfa`') p {{t('auth.tfa.subtitle')}} - .auth-login-tfa - v-otp-input( - ref='tfaIpt' - :num-inputs='6' - :should-auto-focus='true' - input-classes='otp-input' - input-type='number' - separator='' - @on-change='v => state.securityCode = v' - @on-complete='verifyTFA' - ) + v-otp-input( + v-model:value='state.securityCode' + :num-inputs='6' + :should-auto-focus='true' + input-classes='otp-input' + input-type='number' + separator='' + @on-complete='verifyTFA' + ) q-btn.full-width.q-mt-md( push color='primary' @@ -271,7 +270,27 @@ //- TFA SETUP SCREEN //- ----------------------------------------------------- template(v-else-if='state.screen === `tfasetup`') - p TODO - TFA Setup not available yet. + p {{t('auth.tfaSetupTitle')}} + p {{t('auth.tfaSetupInstrFirst')}} + div(style='justify-content: center; display: flex;') + div(v-html='state.tfaQRImage', style='width: 200px;') + p.q-mt-sm {{t('auth.tfaSetupInstrSecond')}} + v-otp-input( + v-model:value='state.securityCode' + :num-inputs='6' + :should-auto-focus='true' + input-classes='otp-input' + input-type='number' + separator='' + ) + q-btn.full-width.q-mt-md( + push + color='primary' + :label='t(`auth.tfa.verifyToken`)' + no-caps + icon='las la-sign-in-alt' + @click='finishSetupTFA' + )