As a user I want to reset my forgotten password via email so I can regain access without contacting the admin #36

Closed
opened 2026-03-20 19:01:47 +01:00 by marcel · 0 comments
Owner

Background

Passwords are set by the admin and users can change them on their profile page (#35). But if a user forgets their password before they change it, they are locked out and must ask the admin to intervene. A self-service reset flow via email removes that friction.

Depends on #35 — the email column on app_users must exist before this can be implemented.

Desired behaviour

  • The login page has a "Forgot password?" link below the submit button
  • Clicking it opens /forgot-password — a simple form with one field: email address
  • The user submits their email; the backend sends a reset link if the address matches a known account (no error is shown either way to avoid user enumeration)
  • The link leads to /reset-password?token=... — a form with two fields: new password and confirmation
  • Submitting a valid, unexpired, unused token updates the password and redirects to /login with a success message
  • Expired or already-used tokens show a clear error with a link back to /forgot-password
  • Reset tokens are valid for 24 hours

Implementation notes

Backend

New Flyway migration — password reset token table:

CREATE TABLE password_reset_tokens (
    token      VARCHAR(64) PRIMARY KEY,
    user_id    UUID        NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
    expires_at TIMESTAMP   NOT NULL,
    used       BOOLEAN     NOT NULL DEFAULT FALSE
);

New service method UserService.requestPasswordReset(email):

  1. Look up user by email — if not found, silently return (no error, no timing difference)
  2. Delete any existing unused tokens for this user before creating a new one — prevents token accumulation from repeated requests
  3. Generate a secure random token (SecureRandom, 256-bit hex)
  4. Persist PasswordResetToken with expires_at = now() + 24h
  5. Send email via Spring JavaMailSender with a link: ${app.base-url}/reset-password?token=...
  6. Catch MailException — if SMTP is not configured or delivery fails, log the error but return normally. The UI always shows "check your inbox" so SMTP misconfiguration does not leak information or produce a 500.

New service method UserService.resetPassword(token, newPassword):

  1. Load token — throw DomainException.notFound if missing
  2. Verify not expired and not used — throw DomainException.conflict if invalid
  3. Update user password hash, mark token as used
  4. Invalidate all sessions for that user (same mechanism as #35 password change)

Token cleanup: A @Scheduled job runs nightly and deletes all tokens where expires_at < now() or used = true. This prevents the table from growing indefinitely.

New endpoints (no authentication required — must be added to the Spring Security permit-list alongside /api/auth/login):

Method Path Purpose
POST /api/auth/forgot-password Triggers reset email
POST /api/auth/reset-password Consumes token, sets new password

Email configuration

All mail settings are externalised as environment variables:

spring.mail.host=${MAIL_HOST:smtp.gmail.com}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME:}
spring.mail.password=${MAIL_PASSWORD:}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

app.mail.from=${MAIL_FROM:${spring.mail.username}}
app.base-url=${APP_BASE_URL:http://localhost:5173}

docker-compose.yml passes MAIL_HOST, MAIL_USERNAME, MAIL_PASSWORD, MAIL_FROM, APP_BASE_URL as env vars so the same image works in both environments.

Frontend

  • New route src/routes/forgot-password/ — email form, always shows "check your inbox" after submit
  • New route src/routes/reset-password/ — reads ?token from URL, shows new-password form; on success redirects to /login with a success message (no auto-login)
  • "Forgot password?" link on the login page
  • i18n keys in de.json / en.json / es.json

Testing

  • UserServiceTest:
    • requestPasswordReset with unknown email does nothing and does not throw
    • requestPasswordReset with known email creates a token and calls JavaMailSender
    • requestPasswordReset when JavaMailSender throws MailException does not propagate the exception
    • requestPasswordReset deletes any existing token for the user before creating a new one
    • resetPassword with expired token throws
    • resetPassword with used token throws
    • resetPassword with valid token updates password hash, marks token used, invalidates sessions
  • @WebMvcTest — both endpoints return 200 regardless of whether email matches
  • E2E Playwright spec — request reset, extract token from DB directly (bypassing email), submit new password, log in with new password

Dependencies

  • #35 must be merged first — email column must exist and be unique

User Journey

User arrives at the login page but can't remember their password. They click the "Forgot password?" link beneath the login form. On the next page they enter their email address and submit. The page always shows a neutral "check your inbox" confirmation — no hint about whether the email is registered.

They open their email, click the reset link, and land on a page with two fields: new password and confirmation. They submit and are redirected to the login page with a success message. They log in immediately with their new password.

If they click an old or already-used reset link, they see a clear error explaining the link has expired, with a link back to the forgot-password page to start over.

E2E Scenarios

Scenario: Request a password reset
  Given I am on the login page
  When I click "Forgot password?"
  And I enter my email address and submit
  Then I see a "check your inbox" message
  And no information is revealed about whether the email is registered

Scenario: Reset password with a valid token
  Given a valid reset token exists in the database for my account
  When I navigate to /reset-password?token=<token>
  And I enter a new password and submit
  Then I am redirected to the login page with a success message
  And I can log in with the new password

Scenario: Expired token is rejected
  Given a reset token exists that has passed its 24-hour expiry
  When I navigate to /reset-password?token=<expired-token>
  Then I see an error message telling me the link has expired
  And I see a link back to the forgot-password page
## Background Passwords are set by the admin and users can change them on their profile page (#35). But if a user forgets their password before they change it, they are locked out and must ask the admin to intervene. A self-service reset flow via email removes that friction. **Depends on #35** — the `email` column on `app_users` must exist before this can be implemented. ## Desired behaviour - The login page has a "Forgot password?" link below the submit button - Clicking it opens `/forgot-password` — a simple form with one field: email address - The user submits their email; the backend sends a reset link if the address matches a known account (no error is shown either way to avoid user enumeration) - The link leads to `/reset-password?token=...` — a form with two fields: new password and confirmation - Submitting a valid, unexpired, unused token updates the password and redirects to `/login` with a success message - Expired or already-used tokens show a clear error with a link back to `/forgot-password` - Reset tokens are valid for **24 hours** ## Implementation notes **Backend** New Flyway migration — password reset token table: ```sql CREATE TABLE password_reset_tokens ( token VARCHAR(64) PRIMARY KEY, user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, expires_at TIMESTAMP NOT NULL, used BOOLEAN NOT NULL DEFAULT FALSE ); ``` New service method `UserService.requestPasswordReset(email)`: 1. Look up user by email — if not found, **silently return** (no error, no timing difference) 2. **Delete any existing unused tokens for this user** before creating a new one — prevents token accumulation from repeated requests 3. Generate a secure random token (`SecureRandom`, 256-bit hex) 4. Persist `PasswordResetToken` with `expires_at = now() + 24h` 5. Send email via Spring `JavaMailSender` with a link: `${app.base-url}/reset-password?token=...` 6. **Catch `MailException`** — if SMTP is not configured or delivery fails, log the error but return normally. The UI always shows "check your inbox" so SMTP misconfiguration does not leak information or produce a 500. New service method `UserService.resetPassword(token, newPassword)`: 1. Load token — throw `DomainException.notFound` if missing 2. Verify not expired and not used — throw `DomainException.conflict` if invalid 3. Update user password hash, mark token as used 4. Invalidate all sessions for that user (same mechanism as #35 password change) **Token cleanup:** A `@Scheduled` job runs nightly and deletes all tokens where `expires_at < now()` or `used = true`. This prevents the table from growing indefinitely. New endpoints (no authentication required — must be added to the Spring Security permit-list alongside `/api/auth/login`): | Method | Path | Purpose | |--------|------|---------| | `POST` | `/api/auth/forgot-password` | Triggers reset email | | `POST` | `/api/auth/reset-password` | Consumes token, sets new password | **Email configuration** All mail settings are externalised as environment variables: ```properties spring.mail.host=${MAIL_HOST:smtp.gmail.com} spring.mail.port=${MAIL_PORT:587} spring.mail.username=${MAIL_USERNAME:} spring.mail.password=${MAIL_PASSWORD:} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true app.mail.from=${MAIL_FROM:${spring.mail.username}} app.base-url=${APP_BASE_URL:http://localhost:5173} ``` `docker-compose.yml` passes `MAIL_HOST`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `APP_BASE_URL` as env vars so the same image works in both environments. **Frontend** - New route `src/routes/forgot-password/` — email form, always shows "check your inbox" after submit - New route `src/routes/reset-password/` — reads `?token` from URL, shows new-password form; on success redirects to `/login` with a success message (no auto-login) - "Forgot password?" link on the login page - i18n keys in `de.json` / `en.json` / `es.json` ## Testing - `UserServiceTest`: - `requestPasswordReset` with unknown email does nothing and does not throw - `requestPasswordReset` with known email creates a token and calls `JavaMailSender` - `requestPasswordReset` when `JavaMailSender` throws `MailException` does not propagate the exception - `requestPasswordReset` deletes any existing token for the user before creating a new one - `resetPassword` with expired token throws - `resetPassword` with used token throws - `resetPassword` with valid token updates password hash, marks token used, invalidates sessions - `@WebMvcTest` — both endpoints return 200 regardless of whether email matches - E2E Playwright spec — request reset, extract token from DB directly (bypassing email), submit new password, log in with new password ## Dependencies - **#35** must be merged first — email column must exist and be unique --- ## User Journey User arrives at the login page but can't remember their password. They click the "Forgot password?" link beneath the login form. On the next page they enter their email address and submit. The page always shows a neutral "check your inbox" confirmation — no hint about whether the email is registered. They open their email, click the reset link, and land on a page with two fields: new password and confirmation. They submit and are redirected to the login page with a success message. They log in immediately with their new password. If they click an old or already-used reset link, they see a clear error explaining the link has expired, with a link back to the forgot-password page to start over. ## E2E Scenarios ``` Scenario: Request a password reset Given I am on the login page When I click "Forgot password?" And I enter my email address and submit Then I see a "check your inbox" message And no information is revealed about whether the email is registered Scenario: Reset password with a valid token Given a valid reset token exists in the database for my account When I navigate to /reset-password?token=<token> And I enter a new password and submit Then I am redirected to the login page with a success message And I can log in with the new password Scenario: Expired token is rejected Given a reset token exists that has passed its 24-hour expiry When I navigate to /reset-password?token=<expired-token> Then I see an error message telling me the link has expired And I see a link back to the forgot-password page ```
marcel added the feature label 2026-03-20 19:11:19 +01:00
marcel added the user label 2026-03-20 19:12:38 +01:00
Sign in to join this conversation.
No Label feature user
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#36