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,183 @@
|
||||
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 ""
|
||||
Reference in new issue
Block a user