mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +00:00
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:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -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
|
||||
|
||||
57
frontend/cypress/e2e/user/password-reset.spec.ts
Normal file
57
frontend/cypress/e2e/user/password-reset.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
29
frontend/cypress/factories/token.ts
Normal file
29
frontend/cypress/factories/token.ts
Normal 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 ?? {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user