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

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

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

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

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

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

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

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

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

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

@ -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`')
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'
)
</template>
<script setup>
@ -459,7 +478,7 @@ async function fetchStrategies (showAll = false) {
useForm
usernameType
}
selfRegistration
registration
}
}
}
@ -475,47 +494,60 @@ async function fetchStrategies (showAll = false) {
async function handleLoginResponse (resp) {
state.continuationToken = resp.continuationToken
if (resp.mustChangePwd === true) {
state.screen = 'changePwd'
nextTick(() => {
if (state.continuationToken) {
changePwdNewPwdIpt.value.focus()
} else {
changePwdCurrentIpt.value.focus()
}
})
$q.loading.hide()
} else if (resp.mustProvideTFA === true) {
state.securityCode = ''
state.screen = 'tfa'
$q.loading.hide()
} else if (resp.mustSetupTFA === true) {
state.securityCode = ''
state.screen = 'tfasetup'
state.tfaQRImage = resp.tfaQRImage
nextTick(() => {
this.$refs.iptTFASetup.focus()
})
$q.loading.hide()
} else {
$q.loading.show({
message: t('auth.loginSuccess')
})
Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
setTimeout(() => {
const loginRedirect = Cookies.get('loginRedirect')
if (loginRedirect === '/' && resp.redirect) {
Cookies.remove('loginRedirect')
window.location.replace(resp.redirect)
} else if (loginRedirect) {
Cookies.remove('loginRedirect')
window.location.replace(loginRedirect)
} else if (resp.redirect) {
window.location.replace(resp.redirect)
} else {
window.location.replace('/')
}
}, 1000)
switch (resp.nextAction) {
case 'changePassword': {
state.screen = 'changePwd'
nextTick(() => {
if (state.continuationToken) {
changePwdNewPwdIpt.value.focus()
} else {
changePwdCurrentIpt.value.focus()
}
})
$q.loading.hide()
break
}
case 'provideTfa': {
state.securityCode = ''
state.screen = 'tfa'
$q.loading.hide()
break
}
case 'setupTfa': {
state.securityCode = ''
state.screen = 'tfasetup'
state.tfaQRImage = resp.tfaQRImage
$q.loading.hide()
break
}
case 'redirect': {
$q.loading.show({
message: t('auth.loginSuccess')
})
Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
setTimeout(() => {
const loginRedirect = Cookies.get('loginRedirect')
if (loginRedirect === '/' && resp.redirect) {
Cookies.remove('loginRedirect')
window.location.replace(resp.redirect)
} else if (loginRedirect) {
Cookies.remove('loginRedirect')
window.location.replace(loginRedirect)
} else if (resp.redirect) {
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!
$password: String!
$strategyId: UUID!
$siteId: UUID
$siteId: UUID!
) {
login(
username: $username
@ -550,9 +582,7 @@ async function login () {
message
}
jwt
mustChangePwd
mustProvideTFA
mustSetupTFA
nextAction
continuationToken
redirect
tfaQRImage
@ -607,6 +637,44 @@ async function register () {
if (!isFormValid) {
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) {
$q.notify({
type: 'negative',
@ -693,21 +761,23 @@ async function verifyTFA () {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation(
continuationToken: String!
securityCode: String!
$continuationToken: String!
$securityCode: String!
$strategyId: UUID!
$siteId: UUID!
) {
loginTFA(
continuationToken: $continuationToken
securityCode: $securityCode
strategyId: $strategyId
siteId: $siteId
) {
operation {
succeeded
message
}
jwt
mustChangePwd
mustProvideTFA
mustSetupTFA
nextAction
continuationToken
redirect
tfaQRImage
@ -716,10 +786,73 @@ async function verifyTFA () {
`,
variables: {
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.securityCode = ''
await handleLoginResponse(resp.data.loginTFA)
@ -744,11 +877,7 @@ onMounted(async () => {
</script>
<style lang="scss">
.auth-login-tfa {
> div {
justify-content: center;
}
.auth-login {
.otp-input {
width: 100%;
height: 48px;

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

Loading…
Cancel
Save