Browse Source

feat: change own password dialog

NGPixel 1 year ago
parent
commit
7f9c3511e8

+ 11 - 4
server/graph/resolvers/authentication.mjs

@@ -127,10 +127,17 @@ export default {
      */
     async changePassword (obj, args, context) {
       try {
-        const authResult = await WIKI.db.users.loginChangePassword(args, context)
-        return {
-          ...authResult,
-          operation: generateSuccess('Password changed successfully')
+        if (args.continuationToken) {
+          const authResult = await WIKI.db.users.loginChangePassword(args, context)
+          return {
+            ...authResult,
+            operation: generateSuccess('Password set successfully')
+          }
+        } else {
+          await WIKI.db.users.changePassword(args, context)
+          return {
+            operation: generateSuccess('Password changed successfully')
+          }
         }
       } catch (err) {
         WIKI.logger.debug(err)

+ 6 - 5
server/graph/resolvers/user.mjs

@@ -41,7 +41,7 @@ export default {
       const usr = await WIKI.db.users.query().findById(args.id)
 
       if (!usr) {
-        throw new Error('Invalid User')
+        throw new Error('ERR_INVALID_USER')
       }
 
       // const str = _.get(WIKI.auth.strategies, usr.providerKey)
@@ -51,10 +51,11 @@ export default {
 
       usr.auth = _.mapValues(usr.auth, (auth, providerKey) => {
         if (auth.password) {
-          auth.password = '***'
+          auth.password = 'redacted'
+        }
+        if (auth.tfaSecret) {
+          auth.tfaSecret = 'redacted'
         }
-        auth.module = providerKey === '00910749-8ab6-498a-9be0-f4ca28ea5e52' ? 'google' : 'local'
-        auth._moduleName = providerKey === '00910749-8ab6-498a-9be0-f4ca28ea5e52' ? 'Google' : 'Local'
         return auth
       })
 
@@ -211,7 +212,7 @@ export default {
     },
     async changeUserPassword (obj, args, context) {
       try {
-        if (args.newPassword?.length < 6) {
+        if (args.newPassword?.length < 8) {
           throw new Error('ERR_PASSWORD_TOO_SHORT')
         }
 

+ 1 - 2
server/graph/schemas/authentication.graphql

@@ -42,12 +42,11 @@ extend type Mutation {
   ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
 
   changePassword(
-    userId: UUID
     continuationToken: String
     currentPassword: String
     newPassword: String!
     strategyId: UUID!
-    siteId: UUID
+    siteId: UUID!
   ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
 
   forgotPassword(

+ 7 - 0
server/graph/schemas/user.graphql

@@ -189,8 +189,15 @@ input UserUpdateInput {
   email: String
   name: String
   groups: [UUID!]
+  auth: UserAuthUpdateInput
   isActive: Boolean
   isVerified: Boolean
   meta: JSON
   prefs: JSON
 }
+
+input UserAuthUpdateInput {
+  tfaRequired: Boolean
+  mustChangePwd: Boolean
+  restrictLogin: Boolean
+}

+ 3 - 1
server/locales/en.json

@@ -1152,6 +1152,7 @@
   "auth.errors.tooManyAttempts": "Too many attempts!",
   "auth.errors.tooManyAttemptsMsg": "You've made too many failed attempts in a short period of time, please try again {time}.",
   "auth.errors.userNotFound": "User not found",
+  "auth.errors.fields": "One or more fields are invalid.",
   "auth.fields.email": "Email Address",
   "auth.fields.emailUser": "Email / Username",
   "auth.fields.name": "Name",
@@ -1197,9 +1198,9 @@
   "auth.tfaFormTitle": "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.tfaSetupSuccess": "2FA enabled successfully on your account.",
   "auth.tfaSetupTitle": "Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.",
   "auth.tfaSetupVerifying": "Verifying...",
-  "auth.tfaSetupSuccess": "2FA enabled successfully on your account.",
   "common.actions.activate": "Activate",
   "common.actions.add": "Add",
   "common.actions.apply": "Apply",
@@ -1746,6 +1747,7 @@
   "profile.appearanceLight": "Light",
   "profile.auth": "Authentication",
   "profile.authChangePassword": "Change Password",
+  "profile.authDisableTfa": "Turn Off 2FA",
   "profile.authInfo": "Your account is associated with the following authentication methods:",
   "profile.authLoadingFailed": "Failed to load authentication methods.",
   "profile.authModifyTfa": "Modify 2FA",

+ 50 - 2
server/models/users.mjs

@@ -497,6 +497,42 @@ export class User extends Model {
     }
   }
 
+  /**
+   * Change Password from Profile
+   */
+  static async changePassword ({ strategyId, siteId, currentPassword, newPassword }, context) {
+    const userId = context.req.user?.id
+    if (!userId) {
+      throw new Error('ERR_USER_NOT_AUTHENTICATED')
+    }
+
+    const user = await WIKI.db.users.query().findById(userId)
+    if (!user) {
+      throw new Error('ERR_USER_NOT_FOUND')
+    }
+
+    if (!newPassword || newPassword.length < 8) {
+      throw new Error('ERR_PASSWORD_TOO_SHORT')
+    }
+
+    if (!user.auth[strategyId]?.password) {
+      throw new Error('ERR_UNEXPECTED_STRATEGY_ID')
+    }
+
+    if (await bcrypt.compare(currentPassword, user.auth[strategyId].password) !== true) {
+      throw new Error('ERR_INCORRECT_CURRENT_PASSWORD')
+    }
+
+    user.auth[strategyId].password = await bcrypt.hash(newPassword, 12)
+    user.auth[strategyId].mustChangePwd = false
+
+    await user.$query().patch({
+      auth: user.auth
+    })
+
+    return true
+  }
+
   /**
    * Send a password reset request
    */
@@ -686,14 +722,14 @@ export class User extends Model {
    *
    * @param {Object} param0 User ID and fields to update
    */
-  static async updateUser (id, { email, name, groups, isVerified, isActive, meta, prefs }) {
+  static async updateUser (id, { email, name, groups, auth, isVerified, isActive, meta, prefs }) {
     const usr = await WIKI.db.users.query().findById(id)
     if (usr) {
       let usrData = {}
       if (!isEmpty(email) && email !== usr.email) {
         const dupUsr = await WIKI.db.users.query().select('id').where({ email }).first()
         if (dupUsr) {
-          throw new WIKI.Error.AuthAccountAlreadyExists()
+          throw new Error('ERR_DUPLICATE_ACCOUNT_EMAIL')
         }
         usrData.email = email.toLowerCase()
       }
@@ -714,6 +750,18 @@ export class User extends Model {
           await usr.$relatedQuery('groups').unrelate().where('groupId', grp)
         }
       }
+      if (!isNil(auth?.tfaRequired)) {
+        usr.auth[WIKI.data.systemIds.localAuthId].tfaRequired = auth.tfaRequired
+        usrData.auth = usr.auth
+      }
+      if (!isNil(auth?.mustChangePwd)) {
+        usr.auth[WIKI.data.systemIds.localAuthId].mustChangePwd = auth.mustChangePwd
+        usrData.auth = usr.auth
+      }
+      if (!isNil(auth?.restrictLogin)) {
+        usr.auth[WIKI.data.systemIds.localAuthId].restrictLogin = auth.restrictLogin
+        usrData.auth = usr.auth
+      }
       if (!isNil(isVerified)) {
         usrData.isVerified = isVerified
       }

+ 1 - 0
ux/public/_assets/icons/ultraviolet-good-pincode.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M0.5 2.5H39.5V19.5H0.5z"/><path fill="#4788c7" d="M39,3v16H1V3H39 M40,2H0v18h40V2L40,2z"/><path fill="#fff" d="M18 11c0 1.133-.867 2-2 2s-2-.867-2-2 .867-2 2-2S18 9.867 18 11zM8 9c-1.133 0-2 .867-2 2s.867 2 2 2 2-.867 2-2S9.133 9 8 9zM32 9c-1.133 0-2 .867-2 2s.867 2 2 2c1.133 0 2-.867 2-2S33.133 9 32 9zM24 9c-1.133 0-2 .867-2 2s.867 2 2 2 2-.867 2-2S25.133 9 24 9z"/><g><path fill="#dff0fe" d="M10.707 31L13.015 28.692 17.087 32.763 27.072 22.779 29.293 25 17 37.293z"/><path fill="#4788c7" d="M27.072,23.487L28.586,25L17,36.586L11.414,31l1.601-1.601l3.365,3.364l0.707,0.707l0.707-0.707 L27.072,23.487 M27.073,22.073l-9.986,9.983l-4.072-4.071L10,31l7,7.001L30,25L27.073,22.073L27.073,22.073z"/></g></svg>

+ 1 - 0
ux/public/_assets/icons/ultraviolet-lock.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="none" stroke="#4788c7" stroke-miterlimit="10" stroke-width="2" d="M30,17.714c0,0,0-5.306,0-5.714 c0-5.523-4.477-10-10-10S10,6.477,10,12c0,0.408,0,5.714,0,5.714"/><path fill="#dff0fe" d="M2.5,37.5V22c0-3.584,2.916-6.5,6.5-6.5h22c3.584,0,6.5,2.916,6.5,6.5v15.5H2.5z"/><path fill="#4788c7" d="M31,16c3.308,0,6,2.692,6,6v15H3V22c0-3.308,2.692-6,6-6H31 M31,15H9c-3.866,0-7,3.134-7,7v16h36V22 C38,18.134,34.866,15,31,15L31,15z"/><g><path fill="#b6dcfe" d="M17.59,32.5l0.891-5.343l-0.289-0.176C17.133,26.336,16.5,25.222,16.5,24c0-1.93,1.57-3.5,3.5-3.5 s3.5,1.57,3.5,3.5c0,1.222-0.633,2.336-1.691,2.981l-0.289,0.176L22.41,32.5H17.59z"/><path fill="#4788c7" d="M20,21c1.654,0,3,1.346,3,3c0,1.046-0.543,2.001-1.452,2.554l-0.578,0.352l0.111,0.667L21.82,32 H18.18l0.738-4.427l0.111-0.667l-0.578-0.352C17.543,26.001,17,25.046,17,24C17,22.346,18.346,21,20,21 M20,20 c-2.209,0-4,1.791-4,4c0,1.449,0.778,2.707,1.932,3.408L17,33h6l-0.932-5.592C23.222,26.707,24,25.449,24,24 C24,21.791,22.209,20,20,20L20,20z"/></g></svg>

+ 1 - 1
ux/src/components/AuthLoginPanel.vue

@@ -703,7 +703,7 @@ async function changePwd () {
           $continuationToken: String
           $newPassword: String!
           $strategyId: UUID!
-          $siteId: UUID
+          $siteId: UUID!
         ) {
           changePassword (
             continuationToken: $continuationToken

+ 248 - 0
ux/src/components/ChangePwdDialog.vue

@@ -0,0 +1,248 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 650px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-password-reset.svg', left, size='sm')
+      span {{t(`admin.users.changePassword`)}}
+    q-form.q-py-sm(ref='changeUserPwdForm', @submit='save')
+      q-item
+        blueprint-icon(icon='lock')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.currentPassword'
+            dense
+            :rules='currentPasswordValidation'
+            hide-bottom-space
+            :label='t(`auth.changePwd.currentPassword`)'
+            :aria-label='t(`auth.changePwd.currentPassword`)'
+            lazy-rules='ondemand'
+            autofocus
+            )
+      q-item
+        blueprint-icon(icon='password')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.newPassword'
+            dense
+            :rules='newPasswordValidation'
+            hide-bottom-space
+            :label='t(`auth.changePwd.newPassword`)'
+            :aria-label='t(`auth.changePwd.newPassword`)'
+            lazy-rules='ondemand'
+            autofocus
+            )
+            template(#append)
+              .flex.items-center
+                q-badge(
+                  :color='passwordStrength.color'
+                  :label='passwordStrength.label'
+                )
+                q-separator.q-mx-sm(vertical)
+                q-btn(
+                  flat
+                  dense
+                  padding='none xs'
+                  color='brown'
+                  @click='randomizePassword'
+                  )
+                  q-icon(name='las la-dice-d6')
+                  .q-pl-xs.text-caption: strong Generate
+      q-item
+        blueprint-icon(icon='good-pincode')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.verifyPassword'
+            dense
+            :rules='verifyPasswordValidation'
+            hide-bottom-space
+            :label='t(`auth.changePwd.newPasswordVerify`)'
+            :aria-label='t(`auth.changePwd.newPasswordVerify`)'
+            lazy-rules='ondemand'
+            autofocus
+            )
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.update`)'
+        color='primary'
+        padding='xs md'
+        @click='save'
+        :loading='state.isLoading'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import zxcvbn from 'zxcvbn'
+import { sampleSize } from 'lodash-es'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { computed, reactive, ref } from 'vue'
+
+import { useSiteStore } from 'src/stores/site'
+
+// PROPS
+
+const props = defineProps({
+  strategyId: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// STORES
+
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  currentPassword: '',
+  newPassword: '',
+  verifyPassword: '',
+  isLoading: false
+})
+
+// REFS
+
+const changeUserPwdForm = ref(null)
+
+// COMPUTED
+
+const passwordStrength = computed(() => {
+  if (state.newPassword.length < 8) {
+    return {
+      color: 'negative',
+      label: t('admin.users.pwdStrengthWeak')
+    }
+  } else {
+    switch (zxcvbn(state.newPassword).score) {
+      case 1:
+        return {
+          color: 'deep-orange-7',
+          label: t('admin.users.pwdStrengthPoor')
+        }
+      case 2:
+        return {
+          color: 'purple-7',
+          label: t('admin.users.pwdStrengthMedium')
+        }
+      case 3:
+        return {
+          color: 'blue-7',
+          label: t('admin.users.pwdStrengthGood')
+        }
+      case 4:
+        return {
+          color: 'green-7',
+          label: t('admin.users.pwdStrengthStrong')
+        }
+      default:
+        return {
+          color: 'negative',
+          label: t('admin.users.pwdStrengthWeak')
+        }
+    }
+  }
+})
+
+// VALIDATION RULES
+
+const currentPasswordValidation = [
+  val => val.length > 0 || t('auth.errors.missingPassword')
+]
+const newPasswordValidation = [
+  val => val.length > 0 || t('auth.errors.missingPassword'),
+  val => val.length >= 8 || t('auth.errors.passwordTooShort')
+]
+const verifyPasswordValidation = [
+  val => val.length > 0 || t('auth.errors.missingVerifyPassword'),
+  val => val === state.newPassword || t('auth.errors.passwordsNotMatch')
+]
+
+// METHODS
+
+function randomizePassword () {
+  const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
+  state.newPassword = sampleSize(pwdChars, 16).join('')
+}
+
+async function save () {
+  state.isLoading = true
+  try {
+    const isFormValid = await changeUserPwdForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('auth.errors.fields'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation changePwd (
+          $currentPassword: String
+          $newPassword: String!
+          $strategyId: UUID!
+          $siteId: UUID!
+          ) {
+          changePassword (
+            currentPassword: $currentPassword
+            newPassword: $newPassword
+            strategyId: $strategyId
+            siteId: $siteId
+            ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        currentPassword: state.currentPassword,
+        newPassword: state.newPassword,
+        strategyId: props.strategyId,
+        siteId: siteStore.id
+      }
+    })
+    if (resp?.data?.changePassword?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('auth.changePwd.success')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.changePassword?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+</script>

+ 7 - 2
ux/src/components/UserEditOverlay.vue

@@ -744,7 +744,12 @@ async function save (patch, { silent, keepOpen } = { silent: false, keepOpen: fa
       isActive: state.user.isActive,
       meta: state.user.meta,
       prefs: state.user.prefs,
-      groups: state.user.groups.map(gr => gr.id)
+      groups: state.user.groups.map(gr => gr.id),
+      auth: {
+        tfaRequired: localAuth.value.isTfaRequired,
+        mustChangePwd: localAuth.value.mustChangePwd,
+        restrictLogin: localAuth.value.restrictLogin
+      }
     }
   }
   try {
@@ -816,7 +821,7 @@ function invalidateTFA () {
       label: t('common.actions.confirm')
     }
   }).onOk(() => {
-    localAuth.value.tfaSecret = ''
+    // TODO: invalidate user 2FA
     $q.notify({
       type: 'positive',
       message: t('admin.users.tfaInvalidateSuccess')

+ 14 - 3
ux/src/pages/ProfileAuth.vue

@@ -25,8 +25,8 @@ q-page.q-py-md(:style-fn='pageStyle')
             q-btn(
               icon='las la-fingerprint'
               unelevated
-              :label='t(`profile.authModifyTfa`)'
-              color='primary'
+              :label='t(`profile.authDisableTfa`)'
+              color='negative'
               @click=''
             )
           q-item-section(v-else, side)
@@ -43,7 +43,7 @@ q-page.q-py-md(:style-fn='pageStyle')
               unelevated
               :label='t(`profile.authChangePassword`)'
               color='primary'
-              @click=''
+              @click='changePassword(auth.authId)'
             )
 
   q-inner-loading(:showing='state.loading > 0')
@@ -57,6 +57,8 @@ import { onMounted, reactive } from 'vue'
 
 import { useUserStore } from 'src/stores/user'
 
+import ChangePwdDialog from 'src/components/ChangePwdDialog.vue'
+
 // QUASAR
 
 const $q = useQuasar()
@@ -128,6 +130,15 @@ async function fetchAuthMethods () {
   state.loading--
 }
 
+function changePassword (strategyId) {
+  $q.dialog({
+    component: ChangePwdDialog,
+    componentProps: {
+      strategyId
+    }
+  })
+}
+
 // MOUNTED
 
 onMounted(() => {