feat: setup TFA

pull/6775/head
NGPixel 1 year ago
parent 7c2b5dd4dd
commit fe8066c8f4
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

@ -133,6 +133,10 @@ editors:
wysiwyg: wysiwyg:
contentType: html contentType: html
config: {} config: {}
systemIds:
localAuthId: '5a528c4c-0a82-4ad2-96a5-2b23811e6588'
guestsGroupId: '10000000-0000-4000-8000-000000000001'
usersGroupId: '20000000-0000-4000-8000-000000000002'
groups: groups:
defaultPermissions: defaultPermissions:
- 'read:pages' - 'read:pages'

@ -79,13 +79,9 @@ export default {
for (const stg of enabledStrategies) { for (const stg of enabledStrategies) {
try { try {
const strategy = (await import(`../modules/authentication/${stg.module}/authentication.mjs`)).default 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` WIKI.auth.strategies[stg.id] = {
stg.config.key = stg.id
strategy.init(passport, stg.config)
strategy.config = stg.config
WIKI.auth.strategies[stg.key] = {
...strategy, ...strategy,
...stg ...stg
} }

@ -66,8 +66,8 @@ export async function up (knex) {
table.boolean('isEnabled').notNullable().defaultTo(false) table.boolean('isEnabled').notNullable().defaultTo(false)
table.string('displayName').notNullable().defaultTo('') table.string('displayName').notNullable().defaultTo('')
table.jsonb('config').notNullable().defaultTo('{}') table.jsonb('config').notNullable().defaultTo('{}')
table.boolean('selfRegistration').notNullable().defaultTo(false) table.boolean('registration').notNullable().defaultTo(false)
table.string('allowedEmailRegex') table.string('allowedEmailRegex').notNullable().defaultTo('')
table.specificType('autoEnrollGroups', 'uuid[]') table.specificType('autoEnrollGroups', 'uuid[]')
}) })
.createTable('blocks', table => { .createTable('blocks', table => {
@ -430,10 +430,10 @@ export async function up (knex) {
// -> GENERATE IDS // -> GENERATE IDS
const groupAdminId = uuid() const groupAdminId = uuid()
const groupUserId = uuid() const groupUserId = WIKI.data.systemIds.usersGroupId
const groupGuestId = '10000000-0000-4000-8000-000000000001' const groupGuestId = WIKI.data.systemIds.guestsGroupId
const siteId = uuid() const siteId = uuid()
const authModuleId = uuid() const authModuleId = WIKI.data.systemIds.localAuthId
const userAdminId = uuid() const userAdminId = uuid()
const userGuestId = uuid() const userGuestId = uuid()
@ -719,7 +719,11 @@ export async function up (knex) {
id: authModuleId, id: authModuleId,
module: 'local', module: 'local',
isEnabled: true, isEnabled: true,
displayName: 'Local Authentication' displayName: 'Local Authentication',
config: JSON.stringify({
emailValidation: true,
enforceTfa: false
})
}) })
// -> USERS // -> USERS
@ -734,6 +738,7 @@ export async function up (knex) {
mustChangePwd: false, // TODO: Revert to true (below) once change password flow is implemented mustChangePwd: false, // TODO: Revert to true (below) once change password flow is implemented
// mustChangePwd: !process.env.ADMIN_PASS, // mustChangePwd: !process.env.ADMIN_PASS,
restrictLogin: false, restrictLogin: false,
tfaIsActive: false,
tfaRequired: false, tfaRequired: false,
tfaSecret: '' tfaSecret: ''
} }

@ -46,7 +46,7 @@ export default {
return { return {
...a, ...a,
config: _.transform(str.props, (r, v, k) => { 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) { if (args.strategy === 'ldap' && WIKI.config.flags.ldapdebug) {
WIKI.logger.warn('LDAP LOGIN ERROR (c1): ', err) WIKI.logger.warn('LDAP LOGIN ERROR (c1): ', err)
} }
console.error(err) WIKI.logger.debug(err)
return generateError(err) return generateError(err)
} }
@ -115,9 +115,10 @@ export default {
const authResult = await WIKI.db.users.loginTFA(args, context) const authResult = await WIKI.db.users.loginTFA(args, context)
return { return {
...authResult, ...authResult,
responseResult: generateSuccess('TFA success') operation: generateSuccess('TFA success')
} }
} catch (err) { } catch (err) {
WIKI.logger.debug(err)
return generateError(err) return generateError(err)
} }
}, },
@ -129,9 +130,10 @@ export default {
const authResult = await WIKI.db.users.loginChangePassword(args, context) const authResult = await WIKI.db.users.loginChangePassword(args, context)
return { return {
...authResult, ...authResult,
responseResult: generateSuccess('Password changed successfully') operation: generateSuccess('Password changed successfully')
} }
} catch (err) { } catch (err) {
WIKI.logger.debug(err)
return generateError(err) return generateError(err)
} }
}, },
@ -142,7 +144,7 @@ export default {
try { try {
await WIKI.db.users.loginForgotPassword(args, context) await WIKI.db.users.loginForgotPassword(args, context)
return { return {
responseResult: generateSuccess('Password reset request processed.') operation: generateSuccess('Password reset request processed.')
} }
} catch (err) { } catch (err) {
return generateError(err) return generateError(err)
@ -153,9 +155,11 @@ export default {
*/ */
async register (obj, args, context) { async register (obj, args, context) {
try { 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 { return {
responseResult: generateSuccess('Registration success') ...authResult,
operation: generateSuccess('Registration success')
} }
} catch (err) { } catch (err) {
return generateError(err) return generateError(err)

@ -30,14 +30,16 @@ extend type Mutation {
username: String! username: String!
password: String! password: String!
strategyId: UUID! strategyId: UUID!
siteId: UUID siteId: UUID!
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60) ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
loginTFA( loginTFA(
continuationToken: String! continuationToken: String!
securityCode: String! securityCode: String!
strategyId: UUID!
siteId: UUID!
setup: Boolean setup: Boolean
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60) ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
changePassword( changePassword(
userId: UUID userId: UUID
@ -46,7 +48,7 @@ extend type Mutation {
newPassword: String! newPassword: String!
strategyId: UUID! strategyId: UUID!
siteId: UUID siteId: UUID
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60) ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
forgotPassword( forgotPassword(
email: String! email: String!
@ -56,7 +58,7 @@ extend type Mutation {
email: String! email: String!
password: String! password: String!
name: String! name: String!
): AuthenticationRegisterResponse ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
refreshToken( refreshToken(
token: String! token: String!
@ -105,7 +107,7 @@ type AuthenticationActiveStrategy {
displayName: String displayName: String
isEnabled: Boolean isEnabled: Boolean
config: JSON config: JSON
selfRegistration: Boolean registration: Boolean
allowedEmailRegex: String allowedEmailRegex: String
autoEnrollGroups: [UUID] autoEnrollGroups: [UUID]
} }
@ -116,22 +118,15 @@ type AuthenticationSiteStrategy {
isVisible: Boolean isVisible: Boolean
} }
type AuthenticationLoginResponse { type AuthenticationAuthResponse {
operation: Operation operation: Operation
jwt: String jwt: String
mustChangePwd: Boolean nextAction: AuthenticationNextAction
mustProvideTFA: Boolean
mustSetupTFA: Boolean
continuationToken: String continuationToken: String
redirect: String redirect: String
tfaQRImage: String tfaQRImage: String
} }
type AuthenticationRegisterResponse {
operation: Operation
jwt: String
}
type AuthenticationTokenResponse { type AuthenticationTokenResponse {
operation: Operation operation: Operation
jwt: String jwt: String
@ -140,11 +135,11 @@ type AuthenticationTokenResponse {
input AuthenticationStrategyInput { input AuthenticationStrategyInput {
key: String! key: String!
strategyKey: String! strategyKey: String!
config: [KeyValuePairInput] config: JSON!
displayName: String! displayName: String!
order: Int! order: Int!
isEnabled: Boolean! isEnabled: Boolean!
selfRegistration: Boolean! registration: Boolean!
allowedEmailRegex: String! allowedEmailRegex: String!
autoEnrollGroups: [UUID]! autoEnrollGroups: [UUID]!
} }
@ -163,3 +158,10 @@ type AuthenticationCreateApiKeyResponse {
operation: Operation operation: Operation
key: String key: String
} }
enum AuthenticationNextAction {
changePassword
setupTfa
provideTfa
redirect
}

@ -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.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.displayName": "Display Name",
"admin.auth.displayNameHint": "The title shown to the end user for this authentication strategy.", "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.enabled": "Enabled",
"admin.auth.enabledForced": "This strategy cannot be disabled.", "admin.auth.enabledForced": "This strategy cannot be disabled.",
"admin.auth.enabledHint": "Should this strategy be available to sites for login.", "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.enforceTfa": "Enforce Two-Factor Authentication",
"admin.auth.force2faHint": "Users will be required to setup 2FA the first time they login and cannot be disabled by the user.", "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.globalAdvSettings": "Global Advanced Settings",
"admin.auth.info": "Info", "admin.auth.info": "Info",
"admin.auth.infoName": "Name", "admin.auth.infoName": "Name",
@ -90,10 +92,10 @@
"admin.auth.noConfigOption": "This strategy has no configuration options you can modify.", "admin.auth.noConfigOption": "This strategy has no configuration options you can modify.",
"admin.auth.refreshSuccess": "List of strategies has been refreshed.", "admin.auth.refreshSuccess": "List of strategies has been refreshed.",
"admin.auth.registration": "Registration", "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.saveSuccess": "Authentication configuration saved successfully.",
"admin.auth.security": "Security", "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.siteUrlNotSetup": "You must set a valid {siteUrl} first! Click on {general} in the left sidebar.",
"admin.auth.status": "Status", "admin.auth.status": "Status",
"admin.auth.strategies": "Strategies", "admin.auth.strategies": "Strategies",
@ -1192,9 +1194,10 @@
"auth.tfa.subtitle": "Security code required:", "auth.tfa.subtitle": "Security code required:",
"auth.tfa.verifyToken": "Verify", "auth.tfa.verifyToken": "Verify",
"auth.tfaFormTitle": "Enter the security code generated from your trusted device:", "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.tfaSetupInstrFirst": "Scan the QR code below from your mobile 2FA application:",
"auth.tfaSetupInstrSecond": "2) Enter the security code generated from your trusted device:", "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.tfaSetupTitle": "Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.",
"auth.tfaSetupVerifying": "Verifying...",
"common.actions.activate": "Activate", "common.actions.activate": "Activate",
"common.actions.add": "Add", "common.actions.add": "Add",
"common.actions.apply": "Apply", "common.actions.apply": "Apply",

@ -47,6 +47,7 @@ export class UserKey extends Model {
} }
static async generateToken ({ userId, kind, meta }, context) { static async generateToken ({ userId, kind, meta }, context) {
WIKI.logger.debug(`Generating ${kind} token for user ${userId}...`)
const token = await nanoid() const token = await nanoid()
await WIKI.db.userKeys.query().insert({ await WIKI.db.userKeys.query().insert({
kind, kind,

@ -9,7 +9,6 @@ import qr from 'qr-image'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { Group } from './groups.mjs' import { Group } from './groups.mjs'
import { Locale } from './locales.mjs'
/** /**
* Users model * Users model
@ -73,35 +72,39 @@ export class User extends Model {
// Instance Methods // Instance Methods
// ------------------------------------------------ // ------------------------------------------------
async generateTFA() { async generateTFA(strategyId, siteId) {
let tfaInfo = tfa.generateSecret({ WIKI.logger.debug(`Generating new TFA secret for user ${this.id}...`)
name: WIKI.config.title, const site = WIKI.sites[siteId] ?? WIKI.sites[0] ?? { config: { title: 'Wiki' }}
const tfaInfo = tfa.generateSecret({
name: site.config.title,
account: this.email account: this.email
}) })
await WIKI.db.users.query().findById(this.id).patch({ this.auth[strategyId].tfaSecret = tfaInfo.secret
tfaIsActive: false, this.auth[strategyId].tfaIsActive = false
tfaSecret: tfaInfo.secret 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' }) return qr.imageSync(`otpauth://totp/${safeTitle}:${this.email}?secret=${tfaInfo.secret}`, { type: 'svg' })
} }
async enableTFA() { async enableTFA(strategyId) {
return WIKI.db.users.query().findById(this.id).patch({ this.auth[strategyId].tfaIsActive = true
tfaIsActive: true return this.$query().patch({
auth: this.auth
}) })
} }
async disableTFA() { async disableTFA(strategyId) {
return this.$query.patch({ this.auth[strategyId].tfaIsActive = false
return this.$query().patch({
tfaIsActive: false, tfaIsActive: false,
tfaSecret: '' tfaSecret: ''
}) })
} }
verifyTFA(code) { verifyTFA(strategyId, code) {
let result = tfa.verifyToken(this.tfaSecret, code) return tfa.verifyToken(this.auth[strategyId].tfaSecret, code)?.delta === 0
return (result && has(result, 'delta') && result.delta === 0)
} }
getPermissions () { getPermissions () {
@ -250,9 +253,9 @@ export class User extends Model {
/** /**
* Login a user * Login a user
*/ */
static async login (opts, context) { static async login ({ strategyId, siteId, username, password }, context) {
if (has(WIKI.auth.strategies, opts.strategy)) { if (has(WIKI.auth.strategies, strategyId)) {
const selStrategy = get(WIKI.auth.strategies, opts.strategy) const selStrategy = WIKI.auth.strategies[strategyId]
if (!selStrategy.isEnabled) { if (!selStrategy.isEnabled) {
throw new WIKI.Error.AuthProviderInvalid() throw new WIKI.Error.AuthProviderInvalid()
} }
@ -261,9 +264,9 @@ export class User extends Model {
// Inject form user/pass // Inject form user/pass
if (strInfo.useForm) { if (strInfo.useForm) {
set(context.req, 'body.email', opts.username) set(context.req, 'body.email', username)
set(context.req, 'body.password', opts.password) set(context.req, 'body.password', password)
set(context.req.params, 'strategy', opts.strategy) set(context.req.params, 'strategy', strategyId)
} }
// Authenticate // Authenticate
@ -277,6 +280,7 @@ export class User extends Model {
try { try {
const resp = await WIKI.db.users.afterLoginChecks(user, selStrategy.id, context, { const resp = await WIKI.db.users.afterLoginChecks(user, selStrategy.id, context, {
siteId,
skipTFA: !strInfo.useForm, skipTFA: !strInfo.useForm,
skipChangePwd: !strInfo.useForm skipChangePwd: !strInfo.useForm
}) })
@ -294,7 +298,12 @@ export class User extends Model {
/** /**
* Perform post-login checks * 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 // Get redirect target
user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions', 'redirectOnLogin') user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions', 'redirectOnLogin')
let redirect = '/' let redirect = '/'
@ -312,14 +321,14 @@ export class User extends Model {
// Is 2FA required? // Is 2FA required?
if (!skipTFA) { if (!skipTFA) {
if (authStr.tfaRequired && authStr.tfaSecret) { if (authStr.tfaIsActive && authStr.tfaSecret) {
try { try {
const tfaToken = await WIKI.db.userKeys.generateToken({ const tfaToken = await WIKI.db.userKeys.generateToken({
kind: 'tfa', kind: 'tfa',
userId: user.id userId: user.id
}) })
return { return {
mustProvideTFA: true, nextAction: 'provideTfa',
continuationToken: tfaToken, continuationToken: tfaToken,
redirect redirect
} }
@ -327,15 +336,15 @@ export class User extends Model {
WIKI.logger.warn(errc) WIKI.logger.warn(errc)
throw new WIKI.Error.AuthGenericError() throw new WIKI.Error.AuthGenericError()
} }
} else if (WIKI.config.auth.enforce2FA || (authStr.tfaIsActive && !authStr.tfaSecret)) { } else if (str.config?.enforceTfa || authStr.tfaRequired) {
try { try {
const tfaQRImage = await user.generateTFA() const tfaQRImage = await user.generateTFA(strategyId, siteId)
const tfaToken = await WIKI.db.userKeys.generateToken({ const tfaToken = await WIKI.db.userKeys.generateToken({
kind: 'tfaSetup', kind: 'tfaSetup',
userId: user.id userId: user.id
}) })
return { return {
mustSetupTFA: true, nextAction: 'setupTfa',
continuationToken: tfaToken, continuationToken: tfaToken,
tfaQRImage, tfaQRImage,
redirect redirect
@ -356,7 +365,7 @@ export class User extends Model {
}) })
return { return {
mustChangePwd: true, nextAction: 'changePassword',
continuationToken: pwdChangeToken, continuationToken: pwdChangeToken,
redirect redirect
} }
@ -370,7 +379,11 @@ export class User extends Model {
context.req.login(user, { session: false }, async errc => { context.req.login(user, { session: false }, async errc => {
if (errc) { return reject(errc) } if (errc) { return reject(errc) }
const jwtToken = await WIKI.db.users.refreshToken(user, strategyId) 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 * 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) { 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', kind: setup ? 'tfaSetup' : 'tfa',
token: continuationToken, token: continuationToken,
skipDelete: setup skipDelete: setup
}) })
if (user) { if (user) {
if (user.verifyTFA(securityCode)) { if (user.verifyTFA(strategyId, securityCode)) {
if (setup) { 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 { } else {
throw new WIKI.Error.AuthTFAFailed() throw new WIKI.Error.AuthTFAFailed()
} }
@ -508,7 +521,14 @@ export class User extends Model {
* *
* @param {Object} param0 User Fields * @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 // Input sanitization
email = email.toLowerCase().trim() email = email.toLowerCase().trim()
@ -547,14 +567,23 @@ export class User extends Model {
throw new Error(`ERR_INVALID_INPUT: ${validation[0]}`) 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 // Check if email already exists
const usr = await WIKI.db.users.query().findOne({ email }) const usr = await WIKI.db.users.query().findOne({ email })
if (usr) { if (usr) {
throw new Error('ERR_ACCOUNT_ALREADY_EXIST') throw new Error('ERR_ACCOUNT_ALREADY_EXIST')
} }
WIKI.logger.debug(`Creating new user account for ${email}...`)
// Create the account // Create the account
const localAuth = await WIKI.db.authentication.getStrategy('local')
const newUsr = await WIKI.db.users.query().insert({ const newUsr = await WIKI.db.users.query().insert({
email, email,
name, name,
@ -583,14 +612,41 @@ export class User extends Model {
dateFormat: WIKI.config.userDefaults.dateFormat || 'YYYY-MM-DD', dateFormat: WIKI.config.userDefaults.dateFormat || 'YYYY-MM-DD',
timeFormat: WIKI.config.userDefaults.timeFormat || '12h' timeFormat: WIKI.config.userDefaults.timeFormat || '12h'
} }
}) }).returning('*')
// Assign to group(s) // Assign to group(s)
if (groups.length > 0) { const groupsToEnroll = [WIKI.data.systemIds.usersGroupId]
await newUsr.$relatedQuery('groups').relate(groups) 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 // Send welcome email
await WIKI.mail.send({ await WIKI.mail.send({
template: 'accountWelcome', 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` 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 * Logout the current user
*/ */

@ -8,8 +8,8 @@ import bcrypt from 'bcryptjs'
import { Strategy } from 'passport-local' import { Strategy } from 'passport-local'
export default { export default {
init (passport, conf) { init (passport, strategyId, conf) {
passport.use(conf.key, passport.use(strategyId,
new Strategy({ new Strategy({
usernameField: 'email', usernameField: 'email',
passwordField: 'password' passwordField: 'password'
@ -19,7 +19,7 @@ export default {
email: uEmail.toLowerCase() email: uEmail.toLowerCase()
}) })
if (user) { if (user) {
const authStrategyData = user.auth[conf.key] const authStrategyData = user.auth[strategyId]
if (!authStrategyData) { if (!authStrategyData) {
throw new WIKI.Error.AuthLoginFailed() throw new WIKI.Error.AuthLoginFailed()
} else if (await bcrypt.compare(uPassword, authStrategyData.password) !== true) { } else if (await bcrypt.compare(uPassword, authStrategyData.password) !== true) {

@ -10,4 +10,16 @@ website: 'https://js.wiki'
isAvailable: true isAvailable: true
useForm: true useForm: true
usernameType: email 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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M4,23.5c-0.827,0-1.5-0.673-1.5-1.5V4c0-0.827,0.673-1.5,1.5-1.5h17c0.827,0,1.5,0.673,1.5,1.5v18 c0,0.827-0.673,1.5-1.5,1.5H4z"/><path fill="#4788c7" d="M21,3c0.551,0,1,0.449,1,1v18c0,0.551-0.449,1-1,1H4c-0.551,0-1-0.449-1-1V4c0-0.551,0.449-1,1-1 H21 M21,2H4C2.895,2,2,2.895,2,4v18c0,1.105,0.895,2,2,2h17c1.105,0,2-0.895,2-2V4C23,2.895,22.105,2,21,2L21,2z"/><path fill="#fff" d="M11 5H15V9H11zM5 5H9V9H5zM11 11H15V15H11zM5 11H9V15H5zM11 17H15V21H11zM5 17H9V21H5z"/><path fill="#dff0fe" d="M25.361,38.5c-2.531,0-4.841-1.179-6.338-3.236l-8.712-10.095 c-1.071-1.073-1.071-2.775-0.025-3.822c0.568-0.568,1.242-0.848,1.959-0.848s1.391,0.279,1.897,0.786l3.357,3.891V7 c0-1.378,1.121-2.5,2.5-2.5s2.5,1.122,2.5,2.5v8.902l0.835-0.752C23.8,14.731,24.392,14.5,25,14.5 c1.034,0,1.973,0.657,2.335,1.634l0.282,0.76l0.553-0.593C28.65,15.784,29.301,15.5,30,15.5c1.042,0,1.981,0.657,2.34,1.635 l0.28,0.766l0.556-0.597C33.657,16.785,34.306,16.5,35,16.5c1.379,0,2.5,1.122,2.5,2.5v11.921c0,4.179-3.399,7.579-7.578,7.579 H25.361z"/><path fill="#4788c7" d="M20,5c1.103,0,2,0.897,2,2v7.779v2.247l1.669-1.504C23.933,15.284,24.379,15,25,15 c0.826,0,1.576,0.526,1.866,1.308l0.564,1.519l1.105-1.185C28.921,16.228,29.441,16,30,16c0.833,0,1.585,0.525,1.87,1.306 l0.56,1.532l1.111-1.194C33.928,17.229,34.446,17,35,17c1.103,0,2,0.897,2,2l0,1v10.921C37,34.825,33.825,38,29.921,38h-4.56 c-2.369,0-4.532-1.104-5.934-3.03l-0.024-0.034l-0.027-0.031l-8.687-10.063l-0.024-0.028l-0.026-0.026 c-0.851-0.851-0.851-2.237,0-3.088l0.061-0.061c0.412-0.412,0.961-0.64,1.544-0.64c0.572,0,1.111,0.219,1.52,0.616l2.478,2.87 L18,26.522l0-2.689L18,22V7C18,5.897,18.897,5,20,5 M20,4c-1.657,0-3,1.343-3,3v15h0v1.833l-2.504-2.9 C13.875,20.311,13.06,20,12.245,20c-0.815,0-1.629,0.311-2.251,0.932l-0.062,0.062c-1.243,1.243-1.243,3.259,0,4.502 l8.687,10.063C20.136,37.642,22.577,39,25.361,39h4.56C34.383,39,38,35.383,38,30.921V20h0v-1c0-1.657-1.343-3-3-3 c-0.868,0-1.643,0.374-2.191,0.963C32.39,15.816,31.296,15,30,15c-0.871,0-1.648,0.372-2.196,0.96C27.38,14.818,26.289,14,25,14 c-0.772,0-1.468,0.3-2,0.779V7C23,5.343,21.657,4,20,4L20,4z"/><path fill="#4788c7" d="M32 17L32 20 33 20 33.009 18.17zM27 16L27 20 28 20 28 17zM22 15L22 20 23 20 23 16z"/><path fill="#4788c7" d="M22.5 19L22.5 19c.275 0 .5.225.5.5v1c0 .275-.225.5-.5.5l0 0c-.275 0-.5-.225-.5-.5v-1C22 19.225 22.225 19 22.5 19zM27.5 19L27.5 19c.275 0 .5.225.5.5v1c0 .275-.225.5-.5.5l0 0c-.275 0-.5-.225-.5-.5v-1C27 19.225 27.225 19 27.5 19zM32.5 19L32.5 19c.275 0 .5.225.5.5v1c0 .275-.225.5-.5.5l0 0c-.275 0-.5-.225-.5-.5v-1C32 19.225 32.225 19 32.5 19z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@ -53,7 +53,8 @@
) )
template(v-if='selectedStrategy.activeStrategy?.strategy?.key === `local`') template(v-if='selectedStrategy.activeStrategy?.strategy?.key === `local`')
q-separator.q-my-md 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 flat
color='primary' color='primary'
:label='t(`auth.switchToRegister.link`)' :label='t(`auth.switchToRegister.link`)'
@ -61,7 +62,7 @@
icon='las la-user-plus' icon='las la-user-plus'
@click='switchTo(`register`)' @click='switchTo(`register`)'
) )
q-btn.acrylic-btn.full-width.q-mt-sm( q-btn.acrylic-btn.full-width(
flat flat
color='primary' color='primary'
:label='t(`auth.forgotPasswordLink`)' :label='t(`auth.forgotPasswordLink`)'
@ -248,17 +249,15 @@
//- ----------------------------------------------------- //- -----------------------------------------------------
template(v-else-if='state.screen === `tfa`') template(v-else-if='state.screen === `tfa`')
p {{t('auth.tfa.subtitle')}} p {{t('auth.tfa.subtitle')}}
.auth-login-tfa v-otp-input(
v-otp-input( v-model:value='state.securityCode'
ref='tfaIpt' :num-inputs='6'
:num-inputs='6' :should-auto-focus='true'
:should-auto-focus='true' input-classes='otp-input'
input-classes='otp-input' input-type='number'
input-type='number' separator=''
separator='' @on-complete='verifyTFA'
@on-change='v => state.securityCode = v' )
@on-complete='verifyTFA'
)
q-btn.full-width.q-mt-md( q-btn.full-width.q-mt-md(
push push
color='primary' color='primary'
@ -271,7 +270,27 @@
//- TFA SETUP SCREEN //- TFA SETUP SCREEN
//- ----------------------------------------------------- //- -----------------------------------------------------
template(v-else-if='state.screen === `tfasetup`') 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'
)
</template> </template>
<script setup> <script setup>
@ -459,7 +478,7 @@ async function fetchStrategies (showAll = false) {
useForm useForm
usernameType usernameType
} }
selfRegistration registration
} }
} }
} }
@ -475,47 +494,60 @@ async function fetchStrategies (showAll = false) {
async function handleLoginResponse (resp) { async function handleLoginResponse (resp) {
state.continuationToken = resp.continuationToken state.continuationToken = resp.continuationToken
if (resp.mustChangePwd === true) { switch (resp.nextAction) {
state.screen = 'changePwd' case 'changePassword': {
nextTick(() => { state.screen = 'changePwd'
if (state.continuationToken) { nextTick(() => {
changePwdNewPwdIpt.value.focus() if (state.continuationToken) {
} else { changePwdNewPwdIpt.value.focus()
changePwdCurrentIpt.value.focus() } else {
} changePwdCurrentIpt.value.focus()
}) }
$q.loading.hide() })
} else if (resp.mustProvideTFA === true) { $q.loading.hide()
state.securityCode = '' break
state.screen = 'tfa' }
$q.loading.hide() case 'provideTfa': {
} else if (resp.mustSetupTFA === true) { state.securityCode = ''
state.securityCode = '' state.screen = 'tfa'
state.screen = 'tfasetup' $q.loading.hide()
state.tfaQRImage = resp.tfaQRImage break
nextTick(() => { }
this.$refs.iptTFASetup.focus() case 'setupTfa': {
}) state.securityCode = ''
$q.loading.hide() state.screen = 'tfasetup'
} else { state.tfaQRImage = resp.tfaQRImage
$q.loading.show({ $q.loading.hide()
message: t('auth.loginSuccess') break
}) }
Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' }) case 'redirect': {
setTimeout(() => { $q.loading.show({
const loginRedirect = Cookies.get('loginRedirect') message: t('auth.loginSuccess')
if (loginRedirect === '/' && resp.redirect) { })
Cookies.remove('loginRedirect') Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
window.location.replace(resp.redirect) setTimeout(() => {
} else if (loginRedirect) { const loginRedirect = Cookies.get('loginRedirect')
Cookies.remove('loginRedirect') if (loginRedirect === '/' && resp.redirect) {
window.location.replace(loginRedirect) Cookies.remove('loginRedirect')
} else if (resp.redirect) { window.location.replace(resp.redirect)
window.location.replace(resp.redirect) } else if (loginRedirect) {
} else { Cookies.remove('loginRedirect')
window.location.replace('/') window.location.replace(loginRedirect)
} } else if (resp.redirect) {
}, 1000) window.location.replace(resp.redirect)
} else {
window.location.replace('/')
}
}, 1000)
break
}
default: {
$q.loading.hide()
$q.notify({
type: 'negative',
message: 'Unexpected Authentication Response'
})
}
} }
} }
@ -537,7 +569,7 @@ async function login () {
$username: String! $username: String!
$password: String! $password: String!
$strategyId: UUID! $strategyId: UUID!
$siteId: UUID $siteId: UUID!
) { ) {
login( login(
username: $username username: $username
@ -550,9 +582,7 @@ async function login () {
message message
} }
jwt jwt
mustChangePwd nextAction
mustProvideTFA
mustSetupTFA
continuationToken continuationToken
redirect redirect
tfaQRImage tfaQRImage
@ -607,6 +637,44 @@ async function register () {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('auth.errors.register')) throw new Error(t('auth.errors.register'))
} }
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation(
$email: String!
$password: String!
$name: String!
) {
register(
email: $email
password: $password
name: $name
) {
operation {
succeeded
message
}
jwt
nextAction
continuationToken
redirect
tfaQRImage
}
}
`,
variables: {
email: state.newEmail,
password: state.newPassword,
name: state.newName
}
})
if (resp.data?.register?.operation?.succeeded) {
state.password = ''
state.newPassword = ''
state.newPasswordVerify = ''
await handleLoginResponse(resp.data.register)
} else {
throw new Error(resp.data?.register?.operation?.message || t('auth.errors.registerError'))
}
} catch (err) { } catch (err) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -693,21 +761,23 @@ async function verifyTFA () {
const resp = await APOLLO_CLIENT.mutate({ const resp = await APOLLO_CLIENT.mutate({
mutation: gql` mutation: gql`
mutation( mutation(
continuationToken: String! $continuationToken: String!
securityCode: String! $securityCode: String!
$strategyId: UUID!
$siteId: UUID!
) { ) {
loginTFA( loginTFA(
continuationToken: $continuationToken continuationToken: $continuationToken
securityCode: $securityCode securityCode: $securityCode
strategyId: $strategyId
siteId: $siteId
) { ) {
operation { operation {
succeeded succeeded
message message
} }
jwt jwt
mustChangePwd nextAction
mustProvideTFA
mustSetupTFA
continuationToken continuationToken
redirect redirect
tfaQRImage tfaQRImage
@ -716,10 +786,73 @@ async function verifyTFA () {
`, `,
variables: { variables: {
continuationToken: state.continuationToken, continuationToken: state.continuationToken,
securityCode: state.securityCode securityCode: state.securityCode,
strategyId: state.selectedStrategyId,
siteId: siteStore.id
} }
}) })
if (resp.data?.login?.operation?.succeeded) { if (resp.data?.loginTFA?.operation?.succeeded) {
state.continuationToken = ''
state.securityCode = ''
await handleLoginResponse(resp.data.loginTFA)
} else {
throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError'))
}
} catch (err) {
$q.loading.hide()
$q.notify({
type: 'negative',
message: err.message
})
}
}
/**
* FINISH TFA SETUP
*/
async function finishSetupTFA () {
$q.loading.show({
message: t('auth.tfaSetupVerifying')
})
try {
if (!/^[0-9]{6}$/.test(state.securityCode)) {
throw new Error(t('auth.errors.tfaMissing'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation(
$continuationToken: String!
$securityCode: String!
$strategyId: UUID!
$siteId: UUID!
) {
loginTFA(
continuationToken: $continuationToken
securityCode: $securityCode
strategyId: $strategyId
siteId: $siteId
setup: true
) {
operation {
succeeded
message
}
jwt
nextAction
continuationToken
redirect
tfaQRImage
}
}
`,
variables: {
continuationToken: state.continuationToken,
securityCode: state.securityCode,
strategyId: state.selectedStrategyId,
siteId: siteStore.id
}
})
if (resp.data?.loginTFA?.operation?.succeeded) {
state.continuationToken = '' state.continuationToken = ''
state.securityCode = '' state.securityCode = ''
await handleLoginResponse(resp.data.loginTFA) await handleLoginResponse(resp.data.loginTFA)
@ -744,11 +877,7 @@ onMounted(async () => {
</script> </script>
<style lang="scss"> <style lang="scss">
.auth-login-tfa { .auth-login {
> div {
justify-content: center;
}
.otp-input { .otp-input {
width: 100%; width: 100%;
height: 48px; height: 48px;

@ -111,17 +111,17 @@ q-page.admin-mail
q-item(tag='label') q-item(tag='label')
blueprint-icon(icon='register') blueprint-icon(icon='register')
q-item-section q-item-section
q-item-label {{t(`admin.auth.selfRegistration`)}} q-item-label {{t(`admin.auth.registration`)}}
q-item-label(caption) {{t(`admin.auth.selfRegistrationHint`)}} q-item-label(caption) {{state.strategy.strategy.key === `local` ? t(`admin.auth.registrationLocalHint`) : t(`admin.auth.registrationHint`)}}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='state.strategy.selfRegistration' v-model='state.strategy.registration'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='t(`admin.auth.selfRegistration`)' :aria-label='t(`admin.auth.registration`)'
) )
template(v-if='state.strategy.selfRegistration') template(v-if='state.strategy.registration')
q-separator.q-my-sm(inset) q-separator.q-my-sm(inset)
q-item q-item
blueprint-icon(icon='team') blueprint-icon(icon='team')
@ -431,7 +431,7 @@ async function load () {
displayName displayName
isEnabled isEnabled
config config
selfRegistration registration
allowedEmailRegex allowedEmailRegex
autoEnrollGroups autoEnrollGroups
} }
@ -504,7 +504,7 @@ function addStrategy (str) {
}, {}), }, {}),
isEnabled: true, isEnabled: true,
displayName: str.title, displayName: str.title,
selfRegistration: true, registration: true,
allowedEmailRegex: '', allowedEmailRegex: '',
autoEnrollGroups: [] autoEnrollGroups: []
} }

Loading…
Cancel
Save