184 lines
13 KiB
Gherkin
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 ""
|