mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-10 08:17:59 -05:00
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:
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user