Files
RenewCMS/features/reset_password.feature

184 lines
13 KiB
Gherkin

Feature: A user sets a new password with their email and reset code, and the code cannot be replayed
As the maintainer of RenewCMS
I want POST /v1/auth/password-reset/confirm to accept the email, reset code and new password
from the link RequestPasswordReset already emailed, set the new password only when the code
matches and has not expired, clear the stored code the moment it is used so it can never be
replayed, and answer an unknown email exactly like a wrong or expired code
So that a forgotten password can be reset without exposing which emails are registered, and a
reset code is single-use by construction rather than by convention
Background:
Given RequestPasswordResetUseCase (features/request_password_reset.feature) already stores a
bcrypt hash of a random reset code on the user, together with PasswordResetExpiration set to
one hour after the request, and emails the raw code and the user's email in a link to
"{PublicURL}/register/reset/validate?c={code}&email={email}"
And this slice adds POST /v1/auth/password-reset/confirm, mounted alongside /register, /login,
/logout and /password-reset in NewAuthRouter, public and outside the jwt-protected group,
since a user resetting a forgotten password cannot be expected to present a jwt
And the request body is decoded into a ResetPasswordCredentials struct with three fields —
Email (validated required,email), Code (validated required) and Password (validated
required,min=8, the same length rule RegisterCredentials already applies to its own Password
field) — by validator.Validate.Struct before anything else
And a decode failure or a validation failure 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; these are shape checks on the request itself, not the
"does this account or code exist" question this slice otherwise hides
And this slice adds a new use case, ResetPasswordUseCase
(internal/infrastructure/useCases/ResetPassword.go), injected into auth.Handler as
ResetPasswordUseCase; its constructor takes only user.Repository and hash.Hasher, the same
shape as VerifyUserUseCase's constructor
And ResetPassword(email, code, newPassword string) error looks the user up with
user.Repository.GetByEmail; when no user has that email, or GetByEmail returns any other
error, it refuses with the new sentinel user.ErrInvalidPasswordResetCode instead of letting
the lookup failure itself distinguish the case, so an unknown email produces exactly the same
outcome as a wrong code
And when a user is found, ResetPassword refuses with the same user.ErrInvalidPasswordResetCode
when the stored PasswordResetCode is empty (never requested, or already used) or when
hash.Hasher.Compare finds the submitted code does not match the stored hash — the same
empty-string-never-matches rule VerifyUserUseCase already applies to VerificationCode, so a
cleared code short-circuits before any hash comparison happens, and is never compared against
a hash of the empty string
And ResetPassword refuses with the new sentinel user.ErrPasswordResetCodeExpired when
time.Now() is after the stored PasswordResetExpiration; the repository is not touched in
either refusal case
And otherwise ResetPassword hashes newPassword with hash.Hasher.Hash and calls a new
user.Repository method, ResetPassword(userId uint32, hashedPassword string) (user.User,
error), that in one write sets Password to the hash, PasswordResetCode to "" (stored as SQL
NULL, the same convention VerificationCode already uses) and PasswordResetExpiration to the
zero time.Time — the same "clear together in one write" pattern ClearVerificationCode already
uses for IsVerified and VerificationCode
And UpdateUserUseCase.UpdatePasswordResetCode, the older generic path that hashes whatever code
it is given, no longer hashes an empty code: given "" it passes "" straight through to
user.Repository.UpdatePasswordResetCode (stored as SQL NULL) without calling hash.Hasher.Hash,
so no code path can ever store a hash of the empty string as a reset code
And because the code is cleared rather than replaced with a hash of an empty string, and an
empty stored code never matches any submitted code, the same code can never be used a second
time even if an attacker knows it; requesting a new reset (RequestPasswordReset) is the only
way to obtain a usable code afterward
And auth.Handler.ResetPassword maps both user.ErrInvalidPasswordResetCode and
user.ErrPasswordResetCodeExpired to the exact same response — 403 with a new
apperr.PasswordResetFailedErrorMessage constant — so a client can never tell an unknown email
apart from a wrong or expired code; any other error is answered 500 "Internal server error"
And on success the handler responds 200 with
{"message": "Your password has been reset. You can now log in."}; it never sets or clears the
jwt cookie and never returns the user record, so a successful reset does not log the caller in
And this slice does not touch RequestPasswordReset, CreateUser or any SPA page; the SPA route
behind "/register/reset/validate?c=...&email=..." calling this endpoint remains a manual/SPA
concern
Scenario: A correct, unexpired code sets the new password and cannot be reused
Given the user repository contains a user "alice" with email "alice@example.com", whose stored
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
one hour from now
When the client sends POST /v1/auth/password-reset/confirm with body
{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}
Then the response status is 200
And the response body is {"message": "Your password has been reset. You can now log in."}
And no jwt cookie is set
And the user repository's record for alice now has a Password hash that is no longer the old
one, an empty PasswordResetCode, and a zero PasswordResetExpiration
When the client sends POST /v1/auth/password-reset/confirm again with the same body
Then the response status is 403
And the response body is the new apperr.PasswordResetFailedErrorMessage text
And the user repository's record for alice still has the password set by the first request
Scenario: A wrong code is refused with the generic message and nothing changes
Given the user repository contains a user "alice" with email "alice@example.com", whose stored
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
one hour from now
When the client sends POST /v1/auth/password-reset/confirm with body
{"email": "alice@example.com", "code": "wrong-code", "password": "newpassword1"}
Then the response status is 403
And the response body is the new apperr.PasswordResetFailedErrorMessage text
And the user repository's record for alice is unchanged: the same stored PasswordResetCode and
the same Password hash as before
Scenario: An expired code is refused with the same generic message and nothing changes
Given the user repository contains a user "alice" with email "alice@example.com", whose stored
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
one hour in the past
When the client sends POST /v1/auth/password-reset/confirm with body
{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}
Then the response status is 403
And the response body is the new apperr.PasswordResetFailedErrorMessage text
And the user repository's record for alice is unchanged
Scenario: An unknown email is refused with the exact same status and body as a wrong code
Given the user repository is empty
When the client sends POST /v1/auth/password-reset/confirm with body
{"email": "nobody@example.com", "code": "any-code", "password": "newpassword1"}
Then the response status is 403
And the response body is the new apperr.PasswordResetFailedErrorMessage text, byte-for-byte
identical to the body a wrong code produces for a known email
Scenario: A user who never requested a reset gets the same refusal, since the stored code is empty
Given the user repository contains a user "alice" with email "alice@example.com" and an empty
PasswordResetCode
When the client sends POST /v1/auth/password-reset/confirm with body
{"email": "alice@example.com", "code": "anything", "password": "newpassword1"}
Then the response status is 403
And the response body is the new apperr.PasswordResetFailedErrorMessage text
Scenario Outline: A malformed or too-short request is rejected before any lookup happens
When the client sends POST /v1/auth/password-reset/confirm with body <body>
Then the response status is 400
Examples:
| body |
| {} |
| {"email": "not-an-email", "code": "c", "password": "newpassword1"} |
| {"email": "alice@example.com", "code": "", "password": "newpassword1"} |
| {"email": "alice@example.com", "code": "reset-code-1", "password": "short"} |
| not-json |
Scenario: The use case itself sets the password and clears the code in one write, independently of any handler
Given a user.Repository fake containing user id 3, email "alice@example.com", whose
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
one hour from now
And a ResetPasswordUseCase built directly over that fake and a hasher
When ResetPassword is called with "alice@example.com", "reset-code-1" and "newpassword1"
Then the call returns nil
And the fake repository's ResetPassword method was invoked exactly once with userId 3 and a
hash of "newpassword1"
Scenario: The use case rejects a wrong code without touching the repository's ResetPassword method
Given a user.Repository fake containing user id 3, email "alice@example.com", whose
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
one hour from now
And a ResetPasswordUseCase built directly over that fake and a hasher
When ResetPassword is called with "alice@example.com", "wrong-code" and "newpassword1"
Then the call returns user.ErrInvalidPasswordResetCode, asserted with errors.Is
And the fake repository's ResetPassword method was never invoked
Scenario: The use case rejects an expired code without touching the repository's ResetPassword method
Given a user.Repository fake containing user id 3, email "alice@example.com", whose
PasswordResetCode is the bcrypt hash of "reset-code-1" and whose PasswordResetExpiration is
one hour in the past
And a ResetPasswordUseCase built directly over that fake and a hasher
When ResetPassword is called with "alice@example.com", "reset-code-1" and "newpassword1"
Then the call returns user.ErrPasswordResetCodeExpired, asserted with errors.Is
And the fake repository's ResetPassword method was never invoked
Scenario: The use case treats an unknown email the same as a wrong code
Given an empty user.Repository fake
And a ResetPasswordUseCase built directly over that fake and a hasher
When ResetPassword is called with "nobody@example.com", "any-code" and "newpassword1"
Then the call returns user.ErrInvalidPasswordResetCode, asserted with errors.Is
And the fake repository's ResetPassword method was never invoked
Scenario: The use case treats an empty stored code the same as a wrong code, never calling Compare with an empty hash
Given a user.Repository fake containing user id 3, email "alice@example.com", whose
PasswordResetCode is empty
And a ResetPasswordUseCase built directly over that fake and a hasher
When ResetPassword is called with "alice@example.com", "anything" and "newpassword1"
Then the call returns user.ErrInvalidPasswordResetCode, asserted with errors.Is
And the fake repository's ResetPassword method was never invoked
Scenario: The generic reset-code update stores an empty code as empty instead of hashing it
Given a user.Repository fake containing user id 3, email "alice@example.com"
And an UpdateUserUseCase built directly over that fake and a hasher
When UpdatePasswordResetCode is called with userId 3, code "" and the zero time.Time
Then the hasher's Hash method was never invoked
And the fake repository's stored PasswordResetCode for id 3 is ""