NGPixel 1 anno fa
parent
commit
fe8066c8f4

+ 4 - 0
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'

+ 2 - 6
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
           }

+ 11 - 6
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: ''
         }

+ 11 - 7
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)

+ 19 - 17
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
+}

+ 9 - 6
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",

+ 1 - 0
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,

+ 101 - 148
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
    */

+ 3 - 3
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) {

+ 13 - 1
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

File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/icons/ultraviolet-pin-pad.svg


+ 201 - 72
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'
+    )
 </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;

+ 7 - 7
ux/src/pages/AdminAuth.vue

@@ -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: []
   }

Some files were not shown because too many files changed in this diff