Refactor password reset to use injected password hasher

Updated backend password reset logic to inject and use a PasswordHasher instance instead of relying on session.security. Adjusted function signatures and calls accordingly. Improved frontend ResetPasswordView with enhanced UI, accessibility, and code documentation, including better validation, error handling, and layout.
This commit is contained in:
João Vitória Silva
2025-10-10 13:56:41 +01:00
parent 6127f0be63
commit 72f379d33b
3 changed files with 279 additions and 99 deletions

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
import password_reset_tokens.schema as password_reset_tokens_schema
import password_reset_tokens.utils as password_reset_tokens_utils
import session.security as session_security
import session.password_hasher as session_password_hasher
import core.database as core_database
import core.apprise as core_apprise
@@ -91,6 +91,10 @@ async def request_password_reset(
@router.post("/password-reset/confirm")
async def confirm_password_reset(
confirm_data: password_reset_tokens_schema.PasswordResetConfirm,
password_hasher: Annotated[
session_password_hasher.PasswordHasher,
Depends(session_password_hasher.get_password_hasher),
],
db: Annotated[
Session,
Depends(core_database.get_db),
@@ -102,6 +106,8 @@ async def confirm_password_reset(
Args:
confirm_data (password_reset_tokens_schema.PasswordResetConfirm):
Data containing the password reset token and the new password.
password_hasher (session_password_hasher.PasswordHasher):
An instance of the password hasher to use for hashing the new password.
db (Session):
Database session dependency.
@@ -113,7 +119,7 @@ async def confirm_password_reset(
"""
# Use the token to reset password
password_reset_tokens_utils.use_password_reset_token(
confirm_data.token, confirm_data.new_password, db
confirm_data.token, confirm_data.new_password, password_hasher, db
)
return {"message": "Password reset successful"}

View File

@@ -14,6 +14,8 @@ import password_reset_tokens.crud as password_reset_tokens_crud
import users.user.crud as users_crud
import session.password_hasher as session_password_hasher
import core.apprise as core_apprise
import core.logger as core_logger
@@ -171,7 +173,12 @@ async def send_password_reset_email(
)
def use_password_reset_token(token: str, new_password: str, db: Session):
def use_password_reset_token(
token: str,
new_password: str,
password_hasher: session_password_hasher.PasswordHasher,
db: Session,
):
"""
Use a password reset token to update a user's password and mark the token as used.
@@ -188,6 +195,8 @@ def use_password_reset_token(token: str, new_password: str, db: Session):
function will hash it before database lookup.
- new_password (str): The new plain-text password to set for the user. Password
validation/hashing is expected to be handled by the underlying users_crud.
- password_hasher (session_password_hasher.PasswordHasher): An instance of the
password hasher to use when updating the user's password.
- db (Session): An active SQLAlchemy Session (or equivalent) used for DB operations.
Transaction management (commit/rollback) is expected to be handled by the caller
or the CRUD functions.
@@ -226,7 +235,9 @@ def use_password_reset_token(token: str, new_password: str, db: Session):
# Update user password
try:
users_crud.edit_user_password(db_token.user_id, new_password, db)
users_crud.edit_user_password(
db_token.user_id, new_password, password_hasher, db
)
# Mark token as used
password_reset_tokens_crud.mark_password_reset_token_used(db_token.id, db)

View File

@@ -1,78 +1,111 @@
<template>
<div class="px-3">
<div class="row justify-content-center">
<div class="col-md-6 p-3 rounded bg-body-tertiary shadow-sm">
<h1>{{ $t('resetPassword.title') }}</h1>
<div class="bg-body-tertiary shadow-sm rounded p-3">
<div class="row justify-content-center align-items-center">
<div class="col d-none d-lg-block">
<img
width="auto"
height="auto"
:src="loginPhotoUrl || '/src/assets/login.png'"
alt="Endurain login illustration"
class="img-fluid rounded"
/>
</div>
<div class="col form-signin m-3">
<form @submit.prevent="submitResetForm">
<div class="mb-3">
<label for="newPassword" class="form-label">{{
$t('resetPassword.newPasswordLabel')
}}</label>
<div class="position-relative">
<input
:type="showNewPassword ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': !isNewPasswordValid && newPassword }"
id="newPassword"
v-model="newPassword"
required
/>
<button
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y me-2"
@click="toggleNewPasswordVisibility"
>
<font-awesome-icon
:icon="showNewPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
/>
</button>
</div>
<div v-if="!isNewPasswordValid && newPassword" class="invalid-feedback d-block">
{{ $t('resetPassword.passwordComplexityError') }}
</div>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">{{
$t('resetPassword.confirmPasswordLabel')
}}</label>
<div class="position-relative">
<input
:type="showConfirmPassword ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': !isPasswordMatch && confirmPassword }"
id="confirmPassword"
v-model="confirmPassword"
required
/>
<button
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y me-2"
@click="toggleConfirmPasswordVisibility"
>
<font-awesome-icon
:icon="showConfirmPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
/>
</button>
</div>
<div v-if="!isPasswordMatch && confirmPassword" class="invalid-feedback d-block">
{{ $t('resetPassword.passwordMismatchError') }}
</div>
</div>
<div class="d-grid gap-2">
<h1 class="mb-3">{{ $t('resetPassword.title') }}</h1>
<!-- New password field -->
<div class="form-floating mb-3 position-relative">
<input
:type="showNewPassword ? 'text' : 'password'"
class="form-control"
id="newPassword"
name="newPassword"
:placeholder="$t('resetPassword.newPasswordLabel')"
:class="{ 'is-invalid': !isNewPasswordValid && newPassword }"
v-model="newPassword"
required
/>
<label for="newPassword">* {{ $t('resetPassword.newPasswordLabel') }}</label>
<button
type="submit"
class="btn btn-primary"
:disabled="!isNewPasswordValid || !isPasswordMatch || resetLoading"
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y"
:class="{ 'me-4': !isNewPasswordValid && newPassword }"
:aria-label="
showNewPassword ? $t('loginView.hidePassword') : $t('loginView.showPassword')
"
@click="toggleNewPasswordVisibility"
>
<span
v-if="resetLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t('resetPassword.submitButton') }}
<font-awesome-icon :icon="showNewPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']" />
</button>
<router-link to="/login" class="btn btn-secondary">
</div>
<div
v-if="!isNewPasswordValid && newPassword"
class="invalid-feedback d-block mb-3"
aria-live="polite"
>
{{ $t('resetPassword.passwordComplexityError') }}
</div>
<!-- Confirm password field -->
<div class="form-floating mb-3 position-relative">
<input
:type="showConfirmPassword ? 'text' : 'password'"
class="form-control"
id="confirmPassword"
name="confirmPassword"
:placeholder="$t('resetPassword.confirmPasswordLabel')"
:class="{ 'is-invalid': !isPasswordMatch && confirmPassword }"
v-model="confirmPassword"
required
/>
<label for="confirmPassword">* {{ $t('resetPassword.confirmPasswordLabel') }}</label>
<button
type="button"
class="btn position-absolute top-50 end-0 translate-middle-y"
:class="{ 'me-4': !isPasswordMatch && confirmPassword }"
:aria-label="
showConfirmPassword ? $t('loginView.hidePassword') : $t('loginView.showPassword')
"
@click="toggleConfirmPasswordVisibility"
>
<font-awesome-icon
:icon="showConfirmPassword ? ['fas', 'eye-slash'] : ['fas', 'eye']"
/>
</button>
</div>
<div
v-if="!isPasswordMatch && confirmPassword"
class="invalid-feedback d-block mb-3"
aria-live="polite"
>
{{ $t('resetPassword.passwordMismatchError') }}
</div>
<!-- Required fields note -->
<p class="text-muted small mb-3">* {{ $t('generalItems.requiredField') }}</p>
<!-- Submit button -->
<button
class="w-100 btn btn-lg btn-primary"
type="submit"
:disabled="!isNewPasswordValid || !isPasswordMatch || resetLoading"
>
<span
v-if="resetLoading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ $t('resetPassword.submitButton') }}
</button>
<!-- Back to login link -->
<div class="mt-3 text-center">
<router-link
to="/login"
class="link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
>
{{ $t('resetPassword.backToLogin') }}
</router-link>
</div>
@@ -82,65 +115,182 @@
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
<script setup lang="ts">
/**
* @fileoverview Password Reset View Component
*
* Provides a secure password reset interface for users who have requested
* a password reset via email. Validates the reset token, enforces password
* complexity requirements, and handles the password update process.
*
* Features:
* - Token validation from URL query parameters
* - Password complexity validation (uppercase, digit, special char, min 8 chars)
* - Password confirmation matching
* - Password visibility toggle
* - Centralized validation using validationUtils
* - Error handling with user-friendly messages
* - Automatic redirect on success/invalid token
*
* Security:
* - Validates token server-side
* - Enforces strong password requirements
* - Single-use tokens with expiration
* - No password exposure in URLs or logs
*
* Related Components:
* - LoginView (redirect destination)
* - ModalComponentEmailInput (forgot password request)
*
* @component
* @example
* // URL with token: /reset-password?token=abc123...
* <ResetPasswordView />
*/
// ============================================================================
// Imports
// ============================================================================
import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
import { useRoute, useRouter, type Router, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { push } from 'notivue'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
import { passwordReset } from '@/services/passwordResetService'
import { isValidPassword, passwordsMatch } from '@/utils/validationUtils'
import { HTTP_STATUS, extractStatusCode } from '@/constants/httpConstants'
import type { ErrorWithResponse } from '@/types'
const route = useRoute()
const router = useRouter()
// ============================================================================
// Composables & Stores
// ============================================================================
const route: RouteLocationNormalizedLoaded = useRoute()
const router: Router = useRouter()
const { t } = useI18n()
const serverSettingsStore = useServerSettingsStore()
// Form data
const newPassword = ref('')
const confirmPassword = ref('')
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const resetLoading = ref(false)
// ============================================================================
// Reactive State
// ============================================================================
// Get token from query params
const token = route.query.token
// Form fields
const newPassword: Ref<string> = ref('')
const confirmPassword: Ref<string> = ref('')
// Computed properties
const isNewPasswordValid = computed(() => {
// UI state
const showNewPassword: Ref<boolean> = ref(false)
const showConfirmPassword: Ref<boolean> = ref(false)
const resetLoading: Ref<boolean> = ref(false)
// Token from URL query parameter
const token: Ref<string | undefined> = ref(route.query.token as string | undefined)
// ============================================================================
// Computed Properties
// ============================================================================
/**
* Compute the login photo URL from server settings
* Returns null if no custom photo is set, triggering fallback to default
*/
const loginPhotoUrl: ComputedRef<string | null> = computed(() => {
return serverSettingsStore.serverSettings.login_photo_set
? `${window.env.ENDURAIN_HOST}/server_images/login.png`
: null
})
/**
* Validate new password against complexity requirements
* - Minimum 8 characters
* - At least 1 uppercase letter
* - At least 1 digit
* - At least 1 special character
*
* Returns true if field is empty (not yet touched) to avoid showing
* errors before user interaction
*/
const isNewPasswordValid: ComputedRef<boolean> = computed(() => {
if (!newPassword.value) return true
return isValidPassword(newPassword.value)
})
const isPasswordMatch = computed(() => {
/**
* Validate password confirmation matches new password
* Returns true if field is empty (not yet touched) to avoid showing
* errors before user interaction
*/
const isPasswordMatch: ComputedRef<boolean> = computed(() => {
if (!confirmPassword.value) return true
return passwordsMatch(newPassword.value, confirmPassword.value)
})
// Methods
const toggleNewPasswordVisibility = () => {
// ============================================================================
// UI Interaction Handlers
// ============================================================================
/**
* Toggle new password field visibility
* Toggles between 'text' and 'password' input types
*/
const toggleNewPasswordVisibility = (): void => {
showNewPassword.value = !showNewPassword.value
}
const toggleConfirmPasswordVisibility = () => {
/**
* Toggle confirm password field visibility
* Toggles between 'text' and 'password' input types
*/
const toggleConfirmPasswordVisibility = (): void => {
showConfirmPassword.value = !showConfirmPassword.value
}
const submitResetForm = async () => {
// ============================================================================
// Password Reset Logic
// ============================================================================
/**
* Submit password reset form
*
* Process:
* 1. Validate password complexity and matching
* 2. Send reset request with token and new password
* 3. Handle success: redirect to login with success message
* 4. Handle errors: display appropriate error messages
*
* Error Handling:
* - 400: Invalid or expired token
* - Other: Generic error with details
*
* @async
* @throws {Error} Network or validation errors
*/
const submitResetForm = async (): Promise<void> => {
// Validate before submission
if (!isNewPasswordValid.value || !isPasswordMatch.value) {
return
}
// Validate token presence
if (!token.value) {
push.error(t('resetPassword.invalidOrExpiredToken'))
router.push('/login?passwordResetInvalidLink=true')
return
}
resetLoading.value = true
try {
await passwordReset.confirmPasswordReset({
token: token,
token: token.value,
new_password: newPassword.value
})
// Redirect to login with success message
// Success: redirect to login
router.push('/login?passwordResetSuccess=true')
} catch (error) {
if (error.toString().includes('400')) {
const statusCode = extractStatusCode(error as ErrorWithResponse)
if (statusCode === HTTP_STATUS.BAD_REQUEST) {
push.error(t('resetPassword.invalidOrExpiredToken'))
} else {
push.error(`${t('resetPassword.resetError')} - ${error}`)
@@ -150,9 +300,22 @@ const submitResetForm = async () => {
}
}
// ============================================================================
// Lifecycle Hooks
// ============================================================================
/**
* Component mount lifecycle hook
*
* Validates that a reset token is present in the URL
* If no token is found, redirects to login with error message
*
* This prevents users from accessing the reset form without a valid
* token link from their email
*/
onMounted(() => {
// Check if token is provided
if (!token) {
if (!token.value) {
push.error(t('resetPassword.invalidOrExpiredToken'))
router.push('/login?passwordResetInvalidLink=true')
}
})