feat: change own password dialog

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

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

@ -41,7 +41,7 @@ export default {
const usr = await WIKI.db.users.query().findById(args.id) const usr = await WIKI.db.users.query().findById(args.id)
if (!usr) { if (!usr) {
throw new Error('Invalid User') throw new Error('ERR_INVALID_USER')
} }
// const str = _.get(WIKI.auth.strategies, usr.providerKey) // const str = _.get(WIKI.auth.strategies, usr.providerKey)
@ -51,10 +51,11 @@ export default {
usr.auth = _.mapValues(usr.auth, (auth, providerKey) => { usr.auth = _.mapValues(usr.auth, (auth, providerKey) => {
if (auth.password) { 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 return auth
}) })
@ -211,7 +212,7 @@ export default {
}, },
async changeUserPassword (obj, args, context) { async changeUserPassword (obj, args, context) {
try { try {
if (args.newPassword?.length < 6) { if (args.newPassword?.length < 8) {
throw new Error('ERR_PASSWORD_TOO_SHORT') throw new Error('ERR_PASSWORD_TOO_SHORT')
} }

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

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

@ -1152,6 +1152,7 @@
"auth.errors.tooManyAttempts": "Too many attempts!", "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.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.userNotFound": "User not found",
"auth.errors.fields": "One or more fields are invalid.",
"auth.fields.email": "Email Address", "auth.fields.email": "Email Address",
"auth.fields.emailUser": "Email / Username", "auth.fields.emailUser": "Email / Username",
"auth.fields.name": "Name", "auth.fields.name": "Name",
@ -1197,9 +1198,9 @@
"auth.tfaFormTitle": "Enter the security code generated from your trusted device:", "auth.tfaFormTitle": "Enter the security code generated from your trusted device:",
"auth.tfaSetupInstrFirst": "Scan the QR code below from your mobile 2FA application:", "auth.tfaSetupInstrFirst": "Scan the QR code below from your mobile 2FA application:",
"auth.tfaSetupInstrSecond": "Enter the security code generated from your trusted device:", "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.tfaSetupTitle": "Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.",
"auth.tfaSetupVerifying": "Verifying...", "auth.tfaSetupVerifying": "Verifying...",
"auth.tfaSetupSuccess": "2FA enabled successfully on your account.",
"common.actions.activate": "Activate", "common.actions.activate": "Activate",
"common.actions.add": "Add", "common.actions.add": "Add",
"common.actions.apply": "Apply", "common.actions.apply": "Apply",
@ -1746,6 +1747,7 @@
"profile.appearanceLight": "Light", "profile.appearanceLight": "Light",
"profile.auth": "Authentication", "profile.auth": "Authentication",
"profile.authChangePassword": "Change Password", "profile.authChangePassword": "Change Password",
"profile.authDisableTfa": "Turn Off 2FA",
"profile.authInfo": "Your account is associated with the following authentication methods:", "profile.authInfo": "Your account is associated with the following authentication methods:",
"profile.authLoadingFailed": "Failed to load authentication methods.", "profile.authLoadingFailed": "Failed to load authentication methods.",
"profile.authModifyTfa": "Modify 2FA", "profile.authModifyTfa": "Modify 2FA",

@ -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 * Send a password reset request
*/ */
@ -686,14 +722,14 @@ export class User extends Model {
* *
* @param {Object} param0 User ID and fields to update * @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) const usr = await WIKI.db.users.query().findById(id)
if (usr) { if (usr) {
let usrData = {} let usrData = {}
if (!isEmpty(email) && email !== usr.email) { if (!isEmpty(email) && email !== usr.email) {
const dupUsr = await WIKI.db.users.query().select('id').where({ email }).first() const dupUsr = await WIKI.db.users.query().select('id').where({ email }).first()
if (dupUsr) { if (dupUsr) {
throw new WIKI.Error.AuthAccountAlreadyExists() throw new Error('ERR_DUPLICATE_ACCOUNT_EMAIL')
} }
usrData.email = email.toLowerCase() usrData.email = email.toLowerCase()
} }
@ -714,6 +750,18 @@ export class User extends Model {
await usr.$relatedQuery('groups').unrelate().where('groupId', grp) 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)) { if (!isNil(isVerified)) {
usrData.isVerified = isVerified usrData.isVerified = isVerified
} }

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

After

Width:  |  Height:  |  Size: 818 B

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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

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

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

Loading…
Cancel
Save