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 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 ""