fix(auth): only use query parameters instead of local storage for password reset token (#770)

Resolves https://github.com/go-vikunja/vikunja/issues/682
This commit is contained in:
kolaente
2025-05-14 22:51:45 +02:00
committed by GitHub
parent 6ba4cb8001
commit fdc0860252
11 changed files with 154 additions and 19 deletions

View File

@@ -11,7 +11,7 @@
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"editor.defaultFormatter": "vscode.typescript-language-features"
},
// https://eslint.vuejs.org/user-guide/#editor-integrations

View File

@@ -0,0 +1,57 @@
import {UserFactory, type UserAttributes} from '../../factories/user'
import {TokenFactory, type TokenAttributes} from '../../factories/token'
context('Password Reset', () => {
let user: UserAttributes
beforeEach(() => {
UserFactory.truncate()
TokenFactory.truncate()
user = UserFactory.create(1)[0] as UserAttributes
})
it('Should allow a user to reset their password with a valid token', () => {
const tokenArray = TokenFactory.create(1, {user_id: user.id as number, kind: 1})
const token: TokenAttributes = tokenArray[0] as TokenAttributes
cy.visit(`/?userPasswordReset=${token.token}`)
cy.url().should('include', `/password-reset?token=${token.token}`)
const newPassword = 'newSecurePassword123'
cy.get('input[id=password]').type(newPassword)
cy.get('button').contains('Reset your password').click()
cy.get('.message.success').should('contain', 'The password was updated successfully.')
cy.get('.button').contains('Login').click()
cy.url().should('include', '/login')
// Try to login with the new password
cy.get('input[id=username]').type(user.username)
cy.get('input[id=password]').type(newPassword)
cy.get('.button').contains('Login').click()
cy.url().should('not.include', '/login')
})
it('Should show an error for an invalid token', () => {
cy.visit('/?userPasswordReset=invalidtoken123')
cy.url().should('include', '/password-reset?token=invalidtoken123')
// Attempt to reset password
const newPassword = 'newSecurePassword123'
cy.get('input[id=password]').type(newPassword)
cy.get('button').contains('Reset your password').click()
cy.get('.message').should('contain', 'Invalid token')
})
it('Should redirect to login if no token is present in query param when visiting /password-reset directly', () => {
cy.visit('/password-reset')
cy.url().should('not.include', '/password-reset')
cy.url().should('include', '/login')
})
it('Should redirect to login if userPasswordReset token is not present in query param when visiting root', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
})

View File

@@ -1,10 +1,18 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export interface ProjectAttributes {
id: number | string; // Allow string for '{increment}'
title: string;
owner_id: number;
created: string;
updated: string;
}
export class ProjectFactory extends Factory {
static table = 'projects'
static factory() {
static factory(): Omit<ProjectAttributes, 'id'> & { id: string } { // id is '{increment}' before seeding
const now = new Date()
return {

View File

@@ -0,0 +1,29 @@
import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export interface TokenAttributes {
id: number | string; // Allow string for '{increment}'
user_id: number;
token: string;
kind: number;
created: string;
}
export class TokenFactory extends Factory {
static table = 'user_tokens'
// The factory method itself produces an object where id is '{increment}' (a string)
// before it gets processed by the main create() method in the base Factory class.
static factory(attrs?: Partial<Omit<TokenAttributes, 'id'>>): Omit<TokenAttributes, 'id'> & { id: string } {
const now = new Date()
return {
id: '{increment}', // This is a string
user_id: 1, // Default user_id
token: faker.string.alphanumeric(64),
kind: 1, // TokenPasswordReset
created: now.toISOString(),
...(attrs ?? {}),
}
}
}

View File

@@ -2,16 +2,27 @@ import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
export interface UserAttributes {
id: number | string;
username: string;
password?: string;
status: number;
issuer: string;
language: string;
created: string;
updated: string;
}
export class UserFactory extends Factory {
static table = 'users'
static factory() {
static factory(): Omit<UserAttributes, 'id' | 'password'> & { id: string; password?: string } {
const now = new Date()
return {
id: '{increment}',
username: faker.lorem.word(10) + faker.string.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
id: '{increment}',
username: faker.lorem.word(10) + faker.string.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0,
issuer: 'local',
language: 'en',

View File

@@ -84,8 +84,8 @@ watch(userPasswordReset, (userPasswordReset) => {
return
}
localStorage.setItem('passwordResetToken', userPasswordReset)
router.push({name: 'user.password-reset.reset'})
authStore.setPasswordResetToken(userPasswordReset)
router.push({name: 'user.password-reset.reset', query: { token: userPasswordReset }})
}, { immediate: true })
// setup email verification redirect

View File

@@ -67,7 +67,8 @@
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in",
"registrationDisabled": "Registration is disabled."
"registrationDisabled": "Registration is disabled.",
"passwordResetTokenMissing": "Password reset token is missing."
},
"settings": {
"title": "Settings",

View File

@@ -11,6 +11,8 @@ export default class PasswordResetModel extends AbstractModel<IPasswordReset> im
super()
this.assignData(data)
this.token = localStorage.getItem('passwordResetToken')
if (data.token) {
this.token = data.token
}
}
}

View File

@@ -394,6 +394,17 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
return
}
// Check if password reset token is in query params
const resetToken = to.query.userPasswordReset as string | undefined
if (resetToken) {
authStore.setPasswordResetToken(resetToken)
}
// Redirect to password reset page if we have a token stored
if (authStore.passwordResetToken && to.name !== 'user.password-reset.reset') {
return {name: 'user.password-reset.reset', query: { token: authStore.passwordResetToken }}
}
// Check if the route the user wants to go to is a route which needs authentication. We use this to
// redirect the user after successful login.
const isValidUserAppRoute = ![
@@ -404,7 +415,7 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
'link-share.auth',
'openid.auth',
].includes(to.name as string) &&
localStorage.getItem('passwordResetToken') === null &&
authStore.passwordResetToken === null &&
localStorage.getItem('emailConfirmToken') === null &&
!(to.name === 'home' && (typeof to.query.userPasswordReset !== 'undefined' || typeof to.query.userEmailConfirm !== 'undefined'))
@@ -423,10 +434,6 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
return {name: 'user.login'}
}
if(localStorage.getItem('passwordResetToken') !== null && to.name !== 'user.password-reset.reset') {
return {name: 'user.password-reset.reset'}
}
if(localStorage.getItem('emailConfirmToken') !== null && to.name !== 'user.login') {
return {name: 'user.login'}
}

View File

@@ -68,6 +68,7 @@ export const useAuthStore = defineStore('auth', () => {
const authenticated = ref(false)
const isLinkShareAuth = ref(false)
const needsTotpPasscode = ref(false)
const passwordResetToken = ref<string | null>(null)
const info = ref<IUser | null>(null)
const avatarUrl = ref('')
@@ -149,6 +150,10 @@ export const useAuthStore = defineStore('auth', () => {
needsTotpPasscode.value = newNeedsTotpPasscode
}
function setPasswordResetToken(token: string | null) {
passwordResetToken.value = token
}
function reloadAvatar() {
if (!info.value) return
avatarUrl.value = `${getAvatarUrl(info.value)}&=${new Date().valueOf()}`
@@ -444,6 +449,7 @@ export const useAuthStore = defineStore('auth', () => {
authenticated: readonly(authenticated),
isLinkShareAuth: readonly(isLinkShareAuth),
needsTotpPasscode: readonly(needsTotpPasscode),
passwordResetToken: readonly(passwordResetToken),
info: readonly(info),
avatarUrl: readonly(avatarUrl),
@@ -466,6 +472,7 @@ export const useAuthStore = defineStore('auth', () => {
setAuthenticated,
setIsLinkShareAuth,
setNeedsTotpPasscode,
setPasswordResetToken,
reloadAvatar,
updateLastUserRefresh,

View File

@@ -52,6 +52,9 @@
<script setup lang="ts">
import {ref, reactive} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useAuthStore} from '@/stores/auth'
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
@@ -62,22 +65,32 @@ const credentials = reactive({
password: '',
})
const route = useRoute()
const authStore = useAuthStore()
const {t} = useI18n()
const passwordResetService = reactive(new PasswordResetService())
const errorMsg = ref('')
const successMessage = ref('')
async function resetPassword() {
errorMsg.value = ''
if(credentials.password === '') {
const token = route.query.token as string
if (!token) {
errorMsg.value = t('user.auth.passwordResetTokenMissing')
return
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
if (credentials.password === '') {
return
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password, token: token})
try {
const {message} = await passwordResetService.resetPassword(passwordReset)
successMessage.value = message
localStorage.removeItem('passwordResetToken')
authStore.setPasswordResetToken(null)
} catch (e) {
errorMsg.value = e.response.data.message
}