178 lines
12 KiB
Gherkin
178 lines
12 KiB
Gherkin
Feature: Anyone can request a password reset, and the response never reveals whether the email exists
|
|
|
|
As the maintainer of RenewCMS
|
|
I want POST /v1/auth/password-reset to always answer with the same status and body regardless of
|
|
whether the submitted email belongs to an account, and, when it does, to email that account a
|
|
reset code that expires one hour after it was generated
|
|
So that the endpoint cannot be used to discover which email addresses are registered, and a
|
|
legitimate reset link always uses the server's own configured public URL
|
|
|
|
Background:
|
|
Given POST /v1/auth/password-reset is public and mounted alongside /register, /login and
|
|
/logout in NewAuthRouter, outside the jwt-protected group
|
|
And the request body is decoded into a RequestPasswordResetCredentials struct with one field,
|
|
Email, validated required,email by validator.Validate.Struct before anything else
|
|
And a decode failure or a validation failure (missing or malformed email) is answered exactly
|
|
like every other auth route: apperr.BodyErrorMessage with 400 for a decode failure, or the
|
|
validator's error text with 400 for a validation failure; only the "does this email exist"
|
|
question is hidden, not the shape of the request itself
|
|
And this slice adds a new use case, RequestPasswordResetUseCase
|
|
(internal/infrastructure/useCases/RequestPasswordReset.go), injected into auth.Handler as
|
|
RequestPasswordResetUseCase; its constructor takes user.Repository, hash.Hasher, mail.Repository
|
|
and the configured public URL string, the same shape as CreateUserUseCase, with the public URL
|
|
coming from config.Settings.PublicURL through api/dependecyInjection.go
|
|
And RequestPasswordReset(email string) error looks the user up with user.Repository.GetByEmail;
|
|
when no user has that email, or GetByEmail returns any other error, it does nothing further and
|
|
returns nil
|
|
And when a user is found, RequestPasswordReset generates a random raw code with uuid.NewString()
|
|
(the same generator CreateUser uses for the verification code), hashes it with hash.Hasher.Hash,
|
|
and stores the hash together with a fresh expiration of time.Now().Add(1 * time.Hour) by calling
|
|
user.Repository.UpdatePasswordResetCode(userId uint32, code string, expiration time.Time)
|
|
(user.User, error) — this slice adds the expiration parameter to that existing repository
|
|
method and to UpdateUserUseCase.UpdatePasswordResetCode, which gains the same parameter and
|
|
passes it straight through, and adds a new PasswordResetExpiration time.Time field to
|
|
user.User, to internal/infrastructure/persistence/models.User (a plain, non-nullable column,
|
|
the same shape as VerificationExpiration) and to the UserToDomain/UserToModel mappers; no new
|
|
entry is needed in api/dependecyInjection.go's AutoMigrate call because models.User is already
|
|
migrated there
|
|
And storing a new code and expiration always overwrites whatever was stored before, so only the
|
|
most recently requested code and expiration are ever valid; a user who requests a reset twice
|
|
can only use the second code, and the first one no longer matches once overwritten
|
|
And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's
|
|
email, template "password-reset" (the already-existing
|
|
internal/infrastructure/mailer/templates/password-reset.html, which renders a link from
|
|
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a
|
|
MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is
|
|
the raw (unhashed) code, and whose Email is the user's email
|
|
And RequestPasswordReset never returns a non-nil error to its caller: a failure to hash the
|
|
code, to store it, or to send the email is swallowed the same way a not-found email is, so the
|
|
handler can never tell those cases apart either — sending the request always looks the same to
|
|
the caller regardless of whether the email exists or whether the mail send happened to succeed
|
|
And a failure to hash the code, to store it, or to send the email is now also written to the
|
|
process log via the stdlib log package (the same convention
|
|
internal/infrastructure/mailer/mailRepository.go already uses for its own send failures);
|
|
this is in addition to, not instead of, always returning nil to the caller — the HTTP response
|
|
is unaffected by whether a failure occurred or was logged
|
|
And a reset applies the same way regardless of the user's IsVerified status: an unverified user
|
|
can request and receive a password reset email exactly like a verified one, since resetting a
|
|
password is unrelated to email verification
|
|
And auth.Handler.RequestPasswordReset calls RequestPasswordResetUseCase.RequestPasswordReset and
|
|
then always responds 200 with the same body,
|
|
{"message": "If an account exists for that email, a password reset link has been sent."},
|
|
never sets or clears the jwt cookie, and never branches on the use case's return value
|
|
And this slice does not add the confirmation endpoint that consumes the code (the page behind
|
|
"/register/reset/validate?c=...&email=..." and the POST call it would make); only the request
|
|
step is in scope
|
|
|
|
Scenario: Requesting a reset for an existing email sends the reset email and returns the generic message
|
|
Given the user repository contains a user "alice" with email "alice@example.com" and IsVerified true
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
|
Then the response status is 200
|
|
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
|
And exactly one email was sent to "alice@example.com" using template "password-reset"
|
|
And that email's data has VerificationCode equal to the raw code stored in the repository's
|
|
hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com"
|
|
|
|
Scenario: Requesting a reset for an email with no account returns the identical response and sends no email
|
|
Given the user repository is empty
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "nobody@example.com"}
|
|
Then the response status is 200
|
|
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
|
And no email was sent
|
|
|
|
Scenario: The reset email's Host reflects the injected public URL
|
|
Given a config.Settings value with PublicURL "https://cms.example.com"
|
|
And the auth handler is built from that settings value
|
|
And the user repository contains a user "alice" with email "alice@example.com"
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
|
Then the email sent to "alice@example.com" has data.Host equal to "https://cms.example.com"
|
|
|
|
Scenario: The stored reset code expires one hour after creation
|
|
Given the user repository contains a user "alice" with email "alice@example.com"
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
|
Then alice's stored PasswordResetExpiration is within 5 seconds of time.Now().Add(1 * time.Hour)
|
|
|
|
Scenario: A second request overwrites the first code, invalidating it
|
|
Given the user repository contains a user "alice" with email "alice@example.com"
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
|
Then exactly one email has been sent, carrying raw code "code-1" in its VerificationCode data
|
|
When the client sends POST /v1/auth/password-reset again with body {"email": "alice@example.com"}
|
|
Then exactly two emails have been sent in total, and the second one's VerificationCode data
|
|
differs from "code-1"
|
|
And alice's stored, hashed PasswordResetCode no longer matches "code-1" but matches the raw code
|
|
from the second email
|
|
|
|
Scenario: A mail-send failure for an existing user still returns the same generic response
|
|
Given the user repository contains a user "alice" with email "alice@example.com"
|
|
And the mail repository is set to fail on its next Send call
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
|
Then the response status is 200
|
|
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
|
And no jwt cookie is set
|
|
And a failure was written to the process log naming the mail-send failure
|
|
|
|
Scenario: An unverified user can request a password reset exactly like a verified one
|
|
Given the user repository contains a user "bob" with email "bob@example.com" and IsVerified false
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"}
|
|
Then the response status is 200
|
|
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
|
And exactly one email was sent to "bob@example.com" using template "password-reset"
|
|
|
|
Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern
|
|
When the client sends POST /v1/auth/password-reset with body <body>
|
|
Then the response status is 400
|
|
|
|
Examples:
|
|
| body |
|
|
| {} |
|
|
| {"email": "not-an-email"} |
|
|
| not-json |
|
|
|
|
Scenario: The use case itself hashes the code, stores it with a one-hour expiration, and sends the email
|
|
Given a user.Repository fake containing user id 3, email "alice@example.com"
|
|
And a RequestPasswordResetUseCase built directly over that fake, a hasher, a mail.Repository
|
|
fake and public URL "https://cms.example.com"
|
|
When RequestPasswordReset is called with "alice@example.com"
|
|
Then the call returns nil
|
|
And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3,
|
|
a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour)
|
|
And exactly one email was sent to "alice@example.com" using template "password-reset" whose data
|
|
carries the same raw code that was hashed, and Host "https://cms.example.com"
|
|
|
|
Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer
|
|
Given an empty user.Repository fake
|
|
And a RequestPasswordResetUseCase built directly over that fake, a hasher, and a mail.Repository fake
|
|
When RequestPasswordReset is called with "nobody@example.com"
|
|
Then the call returns nil
|
|
And the fake repository's UpdatePasswordResetCode method was never invoked
|
|
And no email was sent
|
|
|
|
Scenario: The use case swallows a mail repository failure and still returns nil
|
|
Given a user.Repository fake containing user id 3, email "alice@example.com"
|
|
And a RequestPasswordResetUseCase built directly over that fake, a hasher, and a mail.Repository
|
|
fake set to fail on its next Send call
|
|
When RequestPasswordReset is called with "alice@example.com"
|
|
Then the call returns nil
|
|
And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3
|
|
And a failure was written to the process log naming the mail-send failure
|
|
|
|
Scenario: The use case logs and swallows a hashing failure
|
|
Given a user.Repository fake containing user id 3, email "alice@example.com"
|
|
And a RequestPasswordResetUseCase built directly over that fake, a hasher set to fail on its
|
|
next Hash call, and a mail.Repository fake
|
|
When RequestPasswordReset is called with "alice@example.com"
|
|
Then the call returns nil
|
|
And the fake repository's UpdatePasswordResetCode method was never invoked
|
|
And no email was sent
|
|
And a failure was written to the process log naming the hashing failure
|
|
|
|
Scenario: The use case logs and swallows a repository failure while storing the reset code
|
|
Given a user.Repository fake containing user id 3, email "alice@example.com", set to fail on
|
|
its next UpdatePasswordResetCode call
|
|
And a RequestPasswordResetUseCase built directly over that fake, a hasher, and a mail.Repository
|
|
fake
|
|
When RequestPasswordReset is called with "alice@example.com"
|
|
Then the call returns nil
|
|
And no email was sent
|
|
And a failure was written to the process log naming the storage failure
|