128 lines
8.3 KiB
Gherkin
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
|