2
0
Эх сурвалжийг харах

feat: register server-side validation + forgot password UI

Nicolas Giard 6 жил өмнө
parent
commit
78ae137f48

+ 44 - 6
client/components/login.vue

@@ -45,7 +45,7 @@
                       :placeholder='$t("auth:fields.password")'
                       :placeholder='$t("auth:fields.password")'
                       @keyup.enter='login'
                       @keyup.enter='login'
                     )
                     )
-                  template(v-if='screen === "tfa"')
+                  template(v-else-if='screen === "tfa"')
                     .body-2 Enter the security code generated from your trusted device:
                     .body-2 Enter the security code generated from your trusted device:
                     v-text-field.md2.centered.mt-2(
                     v-text-field.md2.centered.mt-2(
                       solo
                       solo
@@ -57,6 +57,18 @@
                       :placeholder='$t("auth:tfa.placeholder")'
                       :placeholder='$t("auth:tfa.placeholder")'
                       @keyup.enter='verifySecurityCode'
                       @keyup.enter='verifySecurityCode'
                     )
                     )
+                  template(v-else-if='screen === "forgot"')
+                    .body-2 {{ $t('auth:forgotPasswordSubtitle') }}
+                    v-text-field.md2.mt-3(
+                      solo
+                      flat
+                      prepend-icon='email'
+                      background-color='grey lighten-4'
+                      hide-details
+                      ref='iptEmailForgot'
+                      v-model='username'
+                      :placeholder='$t("auth:fields.email")'
+                      )
                 v-card-actions.pb-4
                 v-card-actions.pb-4
                   v-spacer
                   v-spacer
                   v-btn.md2(
                   v-btn.md2(
@@ -69,7 +81,7 @@
                     :loading='isLoading'
                     :loading='isLoading'
                     ) {{ $t('auth:actions.login') }}
                     ) {{ $t('auth:actions.login') }}
                   v-btn.md2(
                   v-btn.md2(
-                    v-if='screen === "tfa"'
+                    v-else-if='screen === "tfa"'
                     block
                     block
                     large
                     large
                     color='primary'
                     color='primary'
@@ -77,12 +89,25 @@
                     round
                     round
                     :loading='isLoading'
                     :loading='isLoading'
                     ) {{ $t('auth:tfa.verifyToken') }}
                     ) {{ $t('auth:tfa.verifyToken') }}
+                  v-btn.md2(
+                    v-else-if='screen === "forgot"'
+                    block
+                    large
+                    color='primary'
+                    @click='forgotPasswordSubmit'
+                    round
+                    :loading='isLoading'
+                    ) {{ $t('auth:sendResetPassword') }}
                   v-spacer
                   v-spacer
-                v-card-actions.pb-3(v-if='selectedStrategy.key === "local"')
+                v-card-actions.pb-3(v-if='screen === "login" && selectedStrategy.key === "local"')
                   v-spacer
                   v-spacer
-                  a.caption(href='') {{ $t('auth:forgotPasswordLink') }}
+                  a.caption(@click.stop.prevent='forgotPassword', href='#forgot') {{ $t('auth:forgotPasswordLink') }}
                   v-spacer
                   v-spacer
-                template(v-if='isSocialShown')
+                v-card-actions.pb-3(v-else-if='screen === "forgot"')
+                  v-spacer
+                  a.caption(@click.stop.prevent='screen = `login`', href='#cancelforgot') {{ $t('auth:forgotPasswordCancel') }}
+                  v-spacer
+                template(v-if='screen === "login" && isSocialShown')
                   v-divider
                   v-divider
                   v-card-text.grey.lighten-4.text-xs-center
                   v-card-text.grey.lighten-4.text-xs-center
                     .pb-2.body-2.text-xs-center.grey--text.text--darken-2 {{ $t('auth:orLoginUsingStrategy') }}
                     .pb-2.body-2.text-xs-center.grey--text.text--darken-2 {{ $t('auth:orLoginUsingStrategy') }}
@@ -95,7 +120,7 @@
                         @click='selectStrategy(strategy)'
                         @click='selectStrategy(strategy)'
                         )
                         )
                       span {{ strategy.title }}
                       span {{ strategy.title }}
-                template(v-if='selectedStrategy.selfRegistration')
+                template(v-if='screen === "login" && selectedStrategy.selfRegistration')
                   v-divider
                   v-divider
                   v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"')
                   v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"')
                     v-spacer
                     v-spacer
@@ -286,6 +311,19 @@ export default {
           this.isLoading = false
           this.isLoading = false
         })
         })
       }
       }
+    },
+    forgotPassword() {
+      this.screen = 'forgot'
+      this.$nextTick(() => {
+        this.$refs.iptEmailForgot.focus()
+      })
+    },
+    async forgotPasswordSubmit() {
+      this.$store.commit('showNotification', {
+        style: 'pink',
+        message: 'Coming soon!',
+        icon: 'free_breakfast'
+      })
     }
     }
   },
   },
   apollo: {
   apollo: {

+ 11 - 9
client/components/register.vue

@@ -10,7 +10,7 @@
             offset-lg3, lg6
             offset-lg3, lg6
             offset-xl4, xl4
             offset-xl4, xl4
             )
             )
-            transition(name='zoom')
+            transition(name='fadeUp')
               v-card.elevation-5.md2(v-show='isShown')
               v-card.elevation-5.md2(v-show='isShown')
                 v-toolbar(color='indigo', flat, dense, dark)
                 v-toolbar(color='indigo', flat, dense, dark)
                   v-spacer
                   v-spacer
@@ -43,6 +43,7 @@
                     :placeholder='$t("auth:fields.password")'
                     :placeholder='$t("auth:fields.password")'
                     color='indigo'
                     color='indigo'
                     loading
                     loading
+                    counter='255'
                     )
                     )
                     password-strength(slot='progress', v-model='password')
                     password-strength(slot='progress', v-model='password')
                   v-text-field.md2.mt-2(
                   v-text-field.md2.mt-2(
@@ -63,12 +64,12 @@
                     flat
                     flat
                     prepend-icon='person'
                     prepend-icon='person'
                     background-color='grey lighten-4'
                     background-color='grey lighten-4'
-                    hide-details
                     ref='iptName'
                     ref='iptName'
                     v-model='name'
                     v-model='name'
                     :placeholder='$t("auth:fields.name")'
                     :placeholder='$t("auth:fields.name")'
                     @keyup.enter='register'
                     @keyup.enter='register'
                     color='indigo'
                     color='indigo'
+                    counter='255'
                     )
                     )
                 v-card-actions.pb-4
                 v-card-actions.pb-4
                   v-spacer
                   v-spacer
@@ -116,7 +117,9 @@ export default {
       name: '',
       name: '',
       hidePassword: true,
       hidePassword: true,
       isLoading: false,
       isLoading: false,
-      isShown: false
+      isShown: false,
+      loaderColor: 'grey darken-4',
+      loaderTitle: 'Working...'
     }
     }
   },
   },
   computed: {
   computed: {
@@ -211,6 +214,8 @@ export default {
           this.$refs.iptName.focus()
           this.$refs.iptName.focus()
         }
         }
       } else {
       } else {
+        this.loaderColor = 'grey darken-4'
+        this.loaderTitle = this.$t('auth:registering')
         this.isLoading = true
         this.isLoading = true
         try {
         try {
           let resp = await this.$apollo.mutate({
           let resp = await this.$apollo.mutate({
@@ -224,11 +229,8 @@ export default {
           if (_.has(resp, 'data.authentication.register')) {
           if (_.has(resp, 'data.authentication.register')) {
             let respObj = _.get(resp, 'data.authentication.register', {})
             let respObj = _.get(resp, 'data.authentication.register', {})
             if (respObj.responseResult.succeeded === true) {
             if (respObj.responseResult.succeeded === true) {
-              this.$store.commit('showNotification', {
-                message: 'Account created successfully! Redirecting...',
-                style: 'success',
-                icon: 'check'
-              })
+              this.loaderColor = 'green'
+              this.loaderTitle = this.$t('auth:registerSuccess')
               Cookies.set('jwt', respObj.jwt, { expires: 365 })
               Cookies.set('jwt', respObj.jwt, { expires: 365 })
               _.delay(() => {
               _.delay(() => {
                 window.location.replace('/')
                 window.location.replace('/')
@@ -237,7 +239,7 @@ export default {
               throw new Error(respObj.responseResult.message)
               throw new Error(respObj.responseResult.message)
             }
             }
           } else {
           } else {
-            throw new Error('Registration is unavailable at this time.')
+            throw new Error(this.$t('auth:genericError'))
           }
           }
         } catch (err) {
         } catch (err) {
           console.error(err)
           console.error(err)

+ 7 - 2
server/controllers/auth.js

@@ -21,8 +21,13 @@ router.get('/logout', function (req, res) {
 /**
 /**
  * Register form
  * Register form
  */
  */
-router.get('/register', function (req, res, next) {
-  res.render('register')
+router.get('/register', async (req, res, next) => {
+  const localStrg = await WIKI.models.authentication.getStrategy('local')
+  if (localStrg.selfRegistration) {
+    res.render('register')
+  } else {
+    next(new WIKI.Error.AuthRegistrationDisabled())
+  }
 })
 })
 
 
 /**
 /**

+ 12 - 0
server/helpers/error.js

@@ -17,6 +17,14 @@ module.exports = {
     message: 'An account already exists using this email address.',
     message: 'An account already exists using this email address.',
     code: 1004
     code: 1004
   }),
   }),
+  AuthRegistrationDisabled: CustomError('AuthRegistrationDisabled', {
+    message: 'Registration is disabled. Contact your system administrator.',
+    code: 1011
+  }),
+  AuthRegistrationDomainUnauthorized: CustomError('AuthRegistrationDomainUnauthorized', {
+    message: 'You are not authorized to register. Must use a whitelisted domain.',
+    code: 1012
+  }),
   AuthTFAFailed: CustomError('AuthTFAFailed', {
   AuthTFAFailed: CustomError('AuthTFAFailed', {
     message: 'Incorrect TFA Security Code.',
     message: 'Incorrect TFA Security Code.',
     code: 1005
     code: 1005
@@ -33,6 +41,10 @@ module.exports = {
     message: 'Too many attempts! Try again later.',
     message: 'Too many attempts! Try again later.',
     code: 1008
     code: 1008
   }),
   }),
+  InputInvalid: CustomError('InputInvalid', {
+    message: 'Input data is invalid.',
+    code: 1013
+  }),
   LocaleInvalidNamespace: CustomError('LocaleInvalidNamespace', {
   LocaleInvalidNamespace: CustomError('LocaleInvalidNamespace', {
     message: 'Invalid locale or namespace.',
     message: 'Invalid locale or namespace.',
     code: 1009
     code: 1009

+ 4 - 0
server/models/authentication.js

@@ -30,6 +30,10 @@ module.exports = class Authentication extends Model {
     }
     }
   }
   }
 
 
+  static async getStrategy(key) {
+    return WIKI.models.authentication.query().findOne({ key })
+  }
+
   static async getStrategies(isEnabled) {
   static async getStrategies(isEnabled) {
     const strategies = await WIKI.models.authentication.query().where(_.isBoolean(isEnabled) ? { isEnabled } : {})
     const strategies = await WIKI.models.authentication.query().where(_.isBoolean(isEnabled) ? { isEnabled } : {})
     return _.sortBy(strategies.map(str => ({
     return _.sortBy(strategies.map(str => ({

+ 62 - 12
server/models/users.js

@@ -6,6 +6,7 @@ const tfa = require('node-2fa')
 const securityHelper = require('../helpers/security')
 const securityHelper = require('../helpers/security')
 const jwt = require('jsonwebtoken')
 const jwt = require('jsonwebtoken')
 const Model = require('objection').Model
 const Model = require('objection').Model
+const validate = require('validate.js')
 
 
 const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/
 const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/
 
 
@@ -294,21 +295,70 @@ module.exports = class User extends Model {
   }
   }
 
 
   static async register ({ email, password, name }, context) {
   static async register ({ email, password, name }, context) {
-    const usr = await WIKI.models.users.query().findOne({ email, providerKey: 'local' })
-    if (!usr) {
-      await WIKI.models.users.query().insert({
-        provider: 'local',
+    const localStrg = await WIKI.models.authentication.getStrategy('local')
+    // Check if self-registration is enabled
+    if (localStrg.selfRegistration) {
+      // Input validation
+      const validation = validate({
         email,
         email,
-        name,
         password,
         password,
-        locale: 'en',
-        defaultEditor: 'markdown',
-        tfaIsActive: false,
-        isSystem: false
-      })
-      return true
+        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) {
+        const emailDomain = _.last(email.split('@'))
+        if (!_.includes(localStrg.domainWhitelist.v, emailDomain)) {
+          throw new WIKI.Error.AuthRegistrationDomainUnauthorized()
+        }
+      }
+      // Check if email already exists
+      const usr = await WIKI.models.users.query().findOne({ email, providerKey: 'local' })
+      if (!usr) {
+        // Create the account
+        await WIKI.models.users.query().insert({
+          provider: 'local',
+          email,
+          name,
+          password,
+          locale: 'en',
+          defaultEditor: 'markdown',
+          tfaIsActive: false,
+          isSystem: false
+        })
+        return true
+      } else {
+        throw new WIKI.Error.AuthAccountAlreadyExists()
+      }
     } else {
     } else {
-      throw new WIKI.Error.AuthAccountAlreadyExists()
+      throw new WIKI.Error.AuthRegistrationDisabled()
     }
     }
   }
   }
 }
 }