Files
RenewCMS/features/account_verification.feature

128 lines
8.3 KiB
Gherkin

Feature: A logged-in user verifies their account with the emailed code
As the maintainer of RenewCMS
I want a logged-in user to confirm ownership of their account by submitting the verification
code that CreateUser already emailed them, have a wrong or expired code refused without
changing anything, have the stored code cleared the moment it is successfully used so it
cannot be replayed, and have a token that carries no usable user_id answered with 401 instead
of a panic
So that account verification is a real security check, not a formality, and a malformed or
hand-crafted token can never crash the server
Background:
Given CreateUserUseCase.CreateUser already stores a bcrypt hash of a random verification code
on the new user, alongside VerificationExpiration set to two hours after creation, and emails
the raw code to the user (features/registration_gate_and_verification_email.feature)
And this slice adds POST /v1/auth/verify, mounted in api/router.go inside a group that applies
jwtauth.Verifier and jwtauth.Authenticator to the auth router, so a request with no jwt
cookie, an expired jwt, or a jwt with a bad signature never reaches the handler and is
answered 401 by jwtauth.Authenticator itself, as already happens for /v1/article routes
And the request body is decoded into a VerifyCredentials struct with one field, Code, validated
required by validator.Validate.Struct before anything else
And a new use case, VerifyUserUseCase (internal/infrastructure/useCases/VerifyUser.go), is
injected into auth.Handler as VerifyUseCase; it depends only on user.Repository and
hash.Hasher and exposes Verify(userId uint32, code string) (user.User, error)
And VerifyUserUseCase.Verify loads the user by id, refuses with the new sentinel
user.ErrInvalidVerificationCode when the stored VerificationCode is empty (already used, or
never set) or hash.Hasher.Compare finds the submitted code does not match the stored hash,
refuses with the new sentinel user.ErrVerificationCodeExpired when
time.Now() is after the stored VerificationExpiration, and otherwise calls a new
user.Repository method, ClearVerificationCode(userId uint32) (user.User, error), that sets
IsVerified true and VerificationCode "" (stored as SQL NULL, the same convention as
PasswordResetCode in features/unverified_users_and_reregistration.feature) in one write
And today, auth.Handler.IsVerified reads token.PrivateClaims()["user_id"].(float64) with a bare
type assertion that panics if the claim is absent or not a number; this slice does not touch
IsVerified, but the new Verify handler extracts user_id from the already-verified token's
claims safely, and answers 401 with a new apperr.UnauthorizedErrorMessage constant whenever
the claim is missing, is not a number, or does not match any user in the repository, instead
of panicking or trusting the value
And a wrong or expired code is mapped to 403 with a new apperr.VerificationFailedErrorMessage
constant, the same status family Login already uses for a rejected credential
And on success the handler responds 200 with the updated user as JSON (is_verified true,
verification_code "")
Scenario: A logged-in user verifies their account with the correct emailed code
Given a user repository containing user id 7, username "alice", is_verified false, whose stored
VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour
from now
And a valid jwt cookie for user id 7
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
Then the response status is 200
And the response body is the user as JSON with is_verified true and verification_code ""
And the user repository's record for id 7 now has is_verified true and an empty
verification_code
Scenario: A wrong code is refused and nothing changes
Given a user repository containing user id 7, username "alice", is_verified false, whose stored
VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour
from now
And a valid jwt cookie for user id 7
When the client sends POST /v1/auth/verify with body {"code": "wrong-code"}
Then the response status is 403
And the response body is "The verification code is invalid or has expired."
And the user repository's record for id 7 still has is_verified false and the same stored
verification_code
Scenario: An expired code is refused and nothing changes
Given a user repository containing user id 7, username "alice", is_verified false, whose stored
VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour
in the past
And a valid jwt cookie for user id 7
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
Then the response status is 403
And the response body is "The verification code is invalid or has expired."
And the user repository's record for id 7 still has is_verified false and the same stored
verification_code
Scenario: The code is cleared once used, so it cannot be replayed
Given a user repository containing user id 7, username "alice", is_verified false, whose stored
VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour
from now
And a valid jwt cookie for user id 7
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
Then the response status is 200
When the client sends POST /v1/auth/verify again with body {"code": "abc123"}
Then the response status is 403
And the response body is "The verification code is invalid or has expired."
Scenario Outline: A token without a valid user_id returns 401 instead of crashing
Given a user repository containing user id 7, username "alice", is_verified false, whose stored
VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour
from now
And <token description>
When the client sends POST /v1/auth/verify with body {"code": "abc123"}
Then the response status is 401
And the response body is the new apperr.UnauthorizedErrorMessage text
And the handler does not panic
And the user repository's record for id 7 still has is_verified false
Examples:
| token description |
| a validly signed jwt cookie whose claims carry no "user_id" key at all |
| a validly signed jwt cookie whose "user_id" claim is the string "seven" |
| a validly signed jwt cookie whose "user_id" claim is the number 999, which does not match any user |
Scenario: The use case itself enforces the rule, independently of any handler
Given a user.Repository fake containing user id 3 with VerificationCode equal to the bcrypt
hash of "right-code" and VerificationExpiration one hour from now
And a VerifyUserUseCase built directly over that fake and a hasher
When Verify is called with userId 3 and code "wrong-code"
Then the call returns user.ErrInvalidVerificationCode, asserted with errors.Is
And the fake repository's ClearVerificationCode method was never invoked
Scenario: The use case rejects an expired code independently of any handler
Given a user.Repository fake containing user id 3 with VerificationCode equal to the bcrypt
hash of "right-code" and VerificationExpiration one hour in the past
And a VerifyUserUseCase built directly over that fake and a hasher
When Verify is called with userId 3 and code "right-code"
Then the call returns user.ErrVerificationCodeExpired, asserted with errors.Is
And the fake repository's ClearVerificationCode method was never invoked
Scenario: The use case succeeds and clears the code independently of any handler
Given a user.Repository fake containing user id 3 with VerificationCode equal to the bcrypt
hash of "right-code" and VerificationExpiration one hour from now
And a VerifyUserUseCase built directly over that fake and a hasher
When Verify is called with userId 3 and code "right-code"
Then the call returns a user with IsVerified true and VerificationCode ""
And the fake repository's ClearVerificationCode method was invoked exactly once with userId 3