refac: reamaining auth surface rewrite, T0
This commit is contained in:
1 parent
6d510d2805
commit
aa865af2e9
50 files changed
+3723
-149
No files matched your search
@@ -0,0 +1,150 @@
|
||||
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 "passwordReset" (the already-existing
|
||||
internal/infrastructure/mailer/templates/passwordReset.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 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 "passwordReset"
|
||||
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
|
||||
|
||||
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 "passwordReset"
|
||||
|
||||
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 "passwordReset" 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
|
||||
Reference in new issue
Block a user