feat: integration v2 and API key PART 1/3
This commit is contained in:
1 parent
7e9bd9ba30
commit
844842fdae
40 files changed
+3782
-64
No files matched your search
@@ -0,0 +1,457 @@
|
||||
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie
|
||||
|
||||
As a developer integrating with RenewCMS
|
||||
I want to create named API keys and send them as a Bearer token
|
||||
So that I can manage articles, media and users without the UI, with exactly the rights of the
|
||||
user who created the key and never more
|
||||
|
||||
Background:
|
||||
Given internal/domain/apikey defines the entity APIKey with ID uint32, UserID uint32, Name,
|
||||
Prefix, HashedKey, ExpiresAt *time.Time, RevokedAt *time.Time, LastUsedAt *time.Time and
|
||||
CreatedAt, with snake_case json tags
|
||||
And internal/domain/apikey defines the sentinels apikey.ErrAPIKeyNotFound,
|
||||
apikey.ErrAPIKeyInvalid and apikey.ErrExpiryNotInFuture
|
||||
And apikey.ErrAPIKeyInvalid covers an unknown, malformed, expired or revoked key and a key
|
||||
whose user is deleted or unverified
|
||||
And internal/domain/apikey defines the interface Repository with Create, Get(id),
|
||||
GetByHashedKey(hashedKey), ListByUser(userId), ListAll(), Revoke(id, at),
|
||||
RevokeAllByUser(userId, at) and TouchLastUsed(id, at)
|
||||
And RevokeAllByUser sets RevokedAt to at on every key of that user that is not yet revoked and
|
||||
leaves the RevokedAt of already revoked keys and every other user's keys untouched
|
||||
And internal/domain/apikey defines the interface KeyGenerator with Generate() returning the
|
||||
raw key, its display prefix and its SHA-256 hash, and Hash(rawKey) returning the SHA-256 hash
|
||||
And internal/infrastructure/hashing implements KeyGenerator, asserted with
|
||||
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
|
||||
And a generated raw key is "rcms_" followed by the base64url encoding, without padding, of 32
|
||||
random bytes, so it matches ^rcms_[A-Za-z0-9_-]{43}$
|
||||
And the display prefix is the first 12 characters of the raw key
|
||||
And only the SHA-256 hash and the prefix are persisted, never the raw key
|
||||
And the use cases CreateAPIKey, ListAPIKeys, RevokeAPIKey and AuthenticateAPIKey depend only
|
||||
on apikey.Repository, apikey.KeyGenerator, user.Repository and an injected clock
|
||||
And ResetPasswordUseCase additionally receives apikey.Repository and an injected clock, and
|
||||
after a successful password reset calls RevokeAllByUser(userId, now); it never reaches
|
||||
persistence directly
|
||||
And persistence has a GORM model, a mapper and a repository for the entity, the model is
|
||||
added to the AutoMigrate list in api/dependecyInjection.go, and wiring happens only in
|
||||
api/dependecyInjection.go
|
||||
And a new middleware resolves "Authorization: Bearer rcms_<...>" to a user and places a jwt in
|
||||
the same jwtauth request context the cookie path fills, with the claims user_id and
|
||||
token_version taken from the user's current record
|
||||
And that middleware runs after jwtauth.Verifier and ahead of jwtauth.Authenticator on the
|
||||
/article routes (public and protected), /user, /media, /settings and GET /auth/me
|
||||
And jwtauth.Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireOwner and every
|
||||
handler stay unchanged
|
||||
And a new middleware RequireSession, mounted ahead of jwtauth.Verifier, answers 403 with
|
||||
apperr.SessionRequiredErrorMessage whenever the Authorization header is a Bearer token that
|
||||
starts with "rcms_", whether that key is valid, expired, revoked, unknown or garbage
|
||||
And RequireSession is mounted on the key management routes POST /v1/api-key, GET /v1/api-key
|
||||
and DELETE /v1/api-key/{id}, on the session-only auth routes POST /v1/auth/verify and
|
||||
DELETE /v1/auth/register, and on every /v1/system route (all under /v1/system/update)
|
||||
And a request on those routes without a "Bearer rcms_" header passes through RequireSession
|
||||
unchanged, so cookie-only and jwt-Bearer behaviour there does not change
|
||||
And the public auth routes login, register, registration, logout, password-reset,
|
||||
password-reset/confirm, invite and invite/accept do not use the key middleware or
|
||||
RequireSession and behave exactly as they do without a key header
|
||||
And the routes are mounted under /v1 inside the jwtauth.Verifier, jwtauth.Authenticator,
|
||||
RequireCurrentToken and RequireVerifiedUser group:
|
||||
POST /v1/api-key, GET /v1/api-key and DELETE /v1/api-key/{id}
|
||||
And a Bearer key on a key-accepting route that cannot authenticate gets 401 with
|
||||
apperr.UnauthorizedErrorMessage, and a request without an rcms_ key keeps the existing
|
||||
jwtauth.Authenticator response, status 401, unchanged by this feature
|
||||
And new apperr strings: APIKeyNotFoundErrorMessage = "API key not found",
|
||||
APIKeyExpiryErrorMessage = "The expiry must be a date in the future." and
|
||||
SessionRequiredErrorMessage = "This action needs a real session."
|
||||
And the per-key rate limit is 120 requests per minute, as constants in api/router.go
|
||||
And enableCorsIfConfigured allows the methods GET, POST, PUT, PATCH, DELETE and OPTIONS
|
||||
And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), admin id 3 "pat"
|
||||
(verified), unverified id 4 "quinn"
|
||||
|
||||
Scenario: Creating a key returns the full key exactly once
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with body {"name": "ci-deploy"}
|
||||
Then the response status is 201
|
||||
And the header Cache-Control is "no-store"
|
||||
And the body has exactly the keys id, name, prefix, key, created_at, expires_at
|
||||
And name is "ci-deploy", expires_at is null, and key matches ^rcms_[A-Za-z0-9_-]{43}$
|
||||
And prefix equals the first 12 characters of key
|
||||
And the stored record has user_id 2 and HashedKey equal to the SHA-256 of key
|
||||
And neither the raw key nor the stored hash appears in any later response
|
||||
|
||||
Scenario: The key can't be retrieved afterwards
|
||||
Given admin 2 created the key "ci-deploy" and holds its raw key
|
||||
When the same jwt cookie sends GET /v1/api-key
|
||||
Then the response status is 200
|
||||
And the body is a JSON array with one entry whose keys are exactly id, user_id, name, prefix,
|
||||
status, created_at, last_used_at, expires_at, revoked_at
|
||||
And the entry has no "key" and no "hashed_key" field
|
||||
And the raw key and its hash appear nowhere in the body
|
||||
And the entry's prefix is the first 12 characters of the raw key
|
||||
|
||||
Scenario: Creating a key with an expiry
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with body
|
||||
{"name": "temp", "expires_at": "<now + 30 days, RFC3339>"}
|
||||
Then the response status is 201
|
||||
And expires_at in the body equals the requested instant
|
||||
|
||||
Scenario Outline: Creating a key with an invalid body is refused and creates nothing
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with <body>
|
||||
Then the response status is 400
|
||||
And the response body is <message>
|
||||
And no key exists in the repository
|
||||
|
||||
Examples:
|
||||
| body | message |
|
||||
| body not-json | apperr.BodyErrorMessage |
|
||||
| body {} | text containing 'Name' and 'required' |
|
||||
| body {"name": ""} | text containing 'Name' and 'required' |
|
||||
| body {"name": "<61 characters>"} | text containing 'Name' and 'max' |
|
||||
| body {"name": "x", "expires_at": "yesterday"} | apperr.BodyErrorMessage |
|
||||
| body {"name": "x", "expires_at": "<now - 1h>"} | apperr.APIKeyExpiryErrorMessage |
|
||||
|
||||
Scenario: A request body can't choose the owner or the role of a new key
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"}
|
||||
Then the response status is 201
|
||||
And the stored key belongs to user 2
|
||||
And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage
|
||||
|
||||
Scenario Outline: Key management needs a verified real session
|
||||
Given <caller>
|
||||
When the client sends POST /v1/api-key with body {"name": "x"}
|
||||
Then the response status is <status>
|
||||
And the response body is <message>
|
||||
And no key exists in the repository
|
||||
|
||||
Examples:
|
||||
| caller | status | message |
|
||||
| no credentials | 401 | the existing Authenticator response |
|
||||
| a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| a stale token_version cookie | 401 | the existing Authenticator response |
|
||||
|
||||
Scenario Outline: A valid key acts as its creator on every key-accepting route
|
||||
Given an active key created by admin 2
|
||||
When the client sends <method> <path> with "Authorization: Bearer <that key>" and no cookie <body>
|
||||
Then the response status is <status>
|
||||
And the effect is attributed to user 2
|
||||
|
||||
Examples:
|
||||
| method | path | body | status |
|
||||
| GET | /v1/settings | | 200 |
|
||||
| GET | /v1/user | | 200 |
|
||||
| PATCH | /v1/user/me | and body {"display_name": "Dev"} | 200 |
|
||||
| POST | /v1/article | and body {"title": "A valid title", "body": "x"} | 201 |
|
||||
| PATCH | /v1/article/1/publish | | 200 |
|
||||
| DELETE | /v1/article/1 | | 204 |
|
||||
| GET | /v1/media | | 200 |
|
||||
| DELETE | /v1/media/1 | | 204 |
|
||||
|
||||
Scenario: A key can read its creator through GET /v1/auth/me
|
||||
Given an active key created by admin 2
|
||||
When the client sends GET /v1/auth/me with that key and no cookie
|
||||
Then the response status is 200
|
||||
And the body is the same JSON the jwt cookie of admin 2 gets on GET /v1/auth/me
|
||||
And the body describes user 2 "dev"
|
||||
|
||||
Scenario: A key that can't authenticate gets 401 on GET /v1/auth/me
|
||||
When the client sends GET /v1/auth/me with "Authorization: Bearer rcms_unknown"
|
||||
Then the response status is 401
|
||||
And the response body is apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: A key sees offline articles on the public article routes, an anonymous caller does not
|
||||
Given an offline article 5 and an online article 6
|
||||
And an active key created by admin 2
|
||||
When the client sends GET /v1/article with that key
|
||||
Then the body lists articles 5 and 6
|
||||
When the client sends GET /v1/article with no credentials
|
||||
Then the body lists only article 6
|
||||
When the client sends GET /v1/article/5 with that key
|
||||
Then the response status is 200
|
||||
When the client sends GET /v1/article/5 with no credentials
|
||||
Then the response status is 404
|
||||
|
||||
Scenario: An invalid key on a public article route is refused, not treated as anonymous
|
||||
When the client sends GET /v1/article with "Authorization: Bearer rcms_unknown"
|
||||
Then the response status is 401
|
||||
And the response body is apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: An owner's key can do owner-only things
|
||||
Given an active key created by owner 1
|
||||
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||
Then the response status is 201
|
||||
And the invite's invited_by is 1
|
||||
|
||||
Scenario: An admin's key can not do owner-only things
|
||||
Given an active key created by admin 2
|
||||
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||
Then the response status is 403
|
||||
And the response body is apperr.OwnerRequiredErrorMessage
|
||||
And no invite exists
|
||||
|
||||
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
|
||||
Given <state>
|
||||
When the client sends GET /v1/settings with "Authorization: Bearer <credential>"
|
||||
Then the response status is 401
|
||||
And the response body is apperr.UnauthorizedErrorMessage
|
||||
And no handler or use case behind the route runs
|
||||
|
||||
Examples:
|
||||
| state | credential |
|
||||
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 |
|
||||
| a key whose expires_at is one second ago | that key |
|
||||
| a key that was revoked | that key |
|
||||
| a key of user 1 whose user record was deleted | that key |
|
||||
| a key of an unverified user | that key |
|
||||
| nothing after the prefix | rcms_ |
|
||||
| a key with one character changed | that key, last character changed |
|
||||
| a key's hash passed instead of the key | the stored HashedKey, status 401 only |
|
||||
|
||||
Scenario: Key behaviour does not change the jwt cookie path
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends GET /v1/settings with only that cookie
|
||||
Then the response status is 200
|
||||
When the client sends GET /v1/settings with an Authorization header "Bearer <a valid jwt>" and no cookie
|
||||
Then the response status is 200
|
||||
When the client sends GET /v1/settings with a stale token_version cookie
|
||||
Then the response status is 401
|
||||
|
||||
Scenario: A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie
|
||||
Given an active key created by admin 3 and a valid jwt cookie for owner 1
|
||||
When the client sends POST /v1/user/invite with both the key and the cookie
|
||||
Then the response status is 403
|
||||
When the client sends GET /v1/settings with "Authorization: Bearer rcms_unknown" and the owner cookie
|
||||
Then the response status is 401
|
||||
|
||||
Scenario Outline: Session-only routes refuse any rcms_ credential with 403
|
||||
Given <state>
|
||||
When the client sends <method> <path> with "Authorization: Bearer <credential>" <body>
|
||||
Then the response status is 403
|
||||
And the response body is apperr.SessionRequiredErrorMessage
|
||||
And no use case behind the route runs
|
||||
And no key was created, listed or revoked
|
||||
|
||||
Examples:
|
||||
| state | credential | method | path | body |
|
||||
| an active key of owner 1 | that key | POST | /v1/api-key | and body {"name": "child"} |
|
||||
| an active key of owner 1 | that key | GET | /v1/api-key | |
|
||||
| an active key of owner 1 | that key | DELETE | /v1/api-key/1 | |
|
||||
| an active key of owner 1 | that key | POST | /v1/auth/verify | and body {"code": "x"} |
|
||||
| an active key of owner 1 | that key | DELETE | /v1/auth/register | |
|
||||
| an active key of owner 1 | that key | GET | /v1/system/update | |
|
||||
| an active key of owner 1 | that key | POST | /v1/system/update/check | |
|
||||
| an active key of owner 1 | that key | POST | /v1/system/update/download | |
|
||||
| an active key of owner 1 | that key | POST | /v1/system/update/restart | |
|
||||
| a revoked key | that key | GET | /v1/api-key | |
|
||||
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 | POST | /v1/system/update/check | |
|
||||
| nothing | rcms_ | DELETE | /v1/auth/register | |
|
||||
|
||||
Scenario Outline: The session-only routes still work as before with a session
|
||||
Given a valid jwt cookie for owner 1
|
||||
When the client sends <method> <path> with only that cookie <body>
|
||||
Then the response is the same as before this feature
|
||||
And a request with no credentials still gets status 401 from the existing Authenticator
|
||||
|
||||
Examples:
|
||||
| method | path | body |
|
||||
| POST | /v1/api-key | and body {"name": "x"} |
|
||||
| GET | /v1/api-key | |
|
||||
| GET | /v1/system/update | |
|
||||
| POST | /v1/system/update/check | |
|
||||
|
||||
Scenario Outline: A key never obtains a session through the public auth routes
|
||||
Given an active key created by owner 1
|
||||
When the client sends <method> <path> with that key and body <body>
|
||||
Then the response is the same as without the key
|
||||
And no jwt cookie is set unless the credentials in the body are valid
|
||||
And the stored user and password are unchanged
|
||||
|
||||
Examples:
|
||||
| method | path | body |
|
||||
| POST | /v1/auth/login | {"username": "marta", "password": "wrong-pass-1"} |
|
||||
| POST | /v1/auth/password-reset/confirm | {"email": "marta@example.com", "code": "nope", "password": "newpassword1"} |
|
||||
|
||||
Scenario: A successful password reset revokes every key of that user
|
||||
Given user 2 has active keys "a" and "b", and each works on GET /v1/settings
|
||||
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with an unexpired expiration
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
And GET /v1/settings with key "a" returns 401 with apperr.UnauthorizedErrorMessage
|
||||
And GET /v1/settings with key "b" returns 401 with apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: A password reset leaves other users' keys untouched
|
||||
Given user 2 has an active key "a" and user 3 has an active key "c"
|
||||
And user 2 has a valid password reset code "reset-code-1"
|
||||
When user 2's password reset is confirmed with "reset-code-1"
|
||||
Then GET /v1/settings with key "c" returns 200
|
||||
And key "c" has no revoked_at
|
||||
|
||||
Scenario: Keys revoked by a password reset show as revoked
|
||||
Given user 2 has active keys "a" and "b", and a clock at T
|
||||
And user 2 has a valid password reset code "reset-code-1"
|
||||
When user 2's password reset is confirmed with "reset-code-1"
|
||||
And admin 2 logs in again with the new password and sends GET /v1/api-key with the new cookie
|
||||
Then the entries "a" and "b" have status "revoked" and revoked_at T
|
||||
|
||||
Scenario: A password reset does not move the revoked_at of a key that was already revoked
|
||||
Given user 2 has a key "old" revoked at T0 and an active key "a"
|
||||
And user 2 has a valid password reset code "reset-code-1"
|
||||
When user 2's password reset is confirmed with "reset-code-1" at a later time T
|
||||
Then key "old" still has revoked_at T0
|
||||
And key "a" has revoked_at T
|
||||
|
||||
Scenario Outline: A failed password reset confirmation revokes nothing
|
||||
Given user 2 has an active key "a"
|
||||
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with <expiration>
|
||||
When the client sends POST /v1/auth/password-reset/confirm with <body>
|
||||
Then the response status is not 200
|
||||
And key "a" has no revoked_at
|
||||
And GET /v1/settings with key "a" returns 200
|
||||
|
||||
Examples:
|
||||
| expiration | body |
|
||||
| an unexpired expiration | {"email": "dev@example.com", "code": "wrong", "password": "newpassword1"} |
|
||||
| an expiration in the past | {"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"} |
|
||||
| an unexpired expiration | {"email": "nobody@example.com", "code": "reset-code-1", "password": "newpassword1"} |
|
||||
|
||||
Scenario: Listing as an admin shows only that admin's keys
|
||||
Given keys "a" and "b" created by admin 2 and key "c" created by admin 3
|
||||
When admin 2 sends GET /v1/api-key with a jwt cookie
|
||||
Then the body lists exactly "a" and "b"
|
||||
|
||||
Scenario: Listing as the owner shows every user's keys, with the creator's id
|
||||
Given the same three keys and key "d" created by owner 1
|
||||
When owner 1 sends GET /v1/api-key with a jwt cookie
|
||||
Then the body lists "a", "b", "c" and "d" newest first
|
||||
And each entry has the user_id of its creator
|
||||
|
||||
Scenario Outline: The list reports each key's state
|
||||
Given a key that is <state>
|
||||
When its creator sends GET /v1/api-key with a jwt cookie
|
||||
Then the entry's status is "<status>", revoked_at is <revoked_at>, and last_used_at is <last_used>
|
||||
|
||||
Examples:
|
||||
| state | status | revoked_at | last_used |
|
||||
| active, never used | active | null | null |
|
||||
| active, used 5 minutes ago | active | null | that time |
|
||||
| expired one hour ago | expired | null | unchanged |
|
||||
| revoked | revoked | a time | unchanged |
|
||||
| both expired and revoked | revoked | a time | unchanged |
|
||||
|
||||
Scenario: Revoking a key ends it immediately
|
||||
Given an active key "ci-deploy" created by admin 2
|
||||
And the key works on GET /v1/settings
|
||||
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||
Then the response status is 204 and the body is empty
|
||||
And GET /v1/settings with that key returns 401 with apperr.UnauthorizedErrorMessage
|
||||
And GET /v1/api-key lists it with status "revoked"
|
||||
|
||||
Scenario: Revoking twice is idempotent
|
||||
Given a key revoked at time T
|
||||
When its creator sends DELETE /v1/api-key/<its id> again
|
||||
Then the response status is 204
|
||||
And revoked_at is still T
|
||||
|
||||
Scenario: An admin can't revoke someone else's key, and learns nothing about it
|
||||
Given a key created by admin 3
|
||||
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||
Then the response status is 404
|
||||
And the response body is apperr.APIKeyNotFoundErrorMessage
|
||||
And the key still works
|
||||
|
||||
Scenario: The owner can revoke any key
|
||||
Given a key created by admin 3
|
||||
When owner 1 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||
Then the response status is 204
|
||||
And the key gets 401 on GET /v1/settings
|
||||
|
||||
Scenario Outline: Revoking with a bad id
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends DELETE /v1/api-key/<id>
|
||||
Then the response status is <status>
|
||||
And the response body is <message>
|
||||
|
||||
Examples:
|
||||
| id | status | message |
|
||||
| abc | 400 | apperr.IdUint32ErrorMessage |
|
||||
| -1 | 400 | apperr.IdUint32ErrorMessage |
|
||||
| 4294967296 | 400 | apperr.IdUint32ErrorMessage |
|
||||
| 999 | 404 | apperr.APIKeyNotFoundErrorMessage |
|
||||
|
||||
Scenario: Using a key records last_used_at, at most once per minute
|
||||
Given an active key never used, and a fake clock at T
|
||||
When the key authenticates a request at T
|
||||
Then last_used_at is T
|
||||
When it authenticates again at T + 30s
|
||||
Then no write happened and last_used_at is still T
|
||||
When it authenticates again at T + 61s
|
||||
Then last_used_at is T + 61s
|
||||
|
||||
Scenario: A failure to record last_used_at doesn't fail the request
|
||||
Given an active key and a repository whose TouchLastUsed returns an error
|
||||
When the client sends GET /v1/settings with that key
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: A rejected key never records last_used_at
|
||||
Given an expired key and a revoked key
|
||||
When each is used on GET /v1/settings
|
||||
Then each gets 401 and no last_used_at is written
|
||||
|
||||
Scenario: Each key has its own rate limit
|
||||
Given two active keys "a" and "b" and a limit of N requests per minute per key
|
||||
When key "a" sends N+1 requests to GET /v1/settings
|
||||
Then request N+1 gets 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header
|
||||
And key "b" still gets 200
|
||||
And a jwt cookie request still gets 200
|
||||
|
||||
Scenario: The rate limit does not apply to unauthenticated requests
|
||||
When the client sends N+1 requests with "Authorization: Bearer rcms_unknown"
|
||||
Then every response is 401, never 429
|
||||
|
||||
Scenario: The CORS preflight allows PATCH
|
||||
Given settings with CorsAllowedOrigins ["https://app.example.com"]
|
||||
When the client sends OPTIONS /v1/article/1 with Origin "https://app.example.com" and
|
||||
Access-Control-Request-Method "PATCH"
|
||||
Then the response is a successful preflight
|
||||
And Access-Control-Allow-Methods includes PATCH
|
||||
And a preflight for method "TRACE" is not allowed
|
||||
|
||||
Scenario: Use case: creating a key hashes it and stores no plaintext
|
||||
Given a fake KeyGenerator returning raw "rcms_fake", prefix "rcms_fake", hash "hash:rcms_fake"
|
||||
When CreateAPIKey is called with user 2 and name "ci"
|
||||
Then the repository's Create receives HashedKey "hash:rcms_fake", Prefix "rcms_fake", UserID 2
|
||||
And the result carries the raw key, which is not stored
|
||||
|
||||
Scenario: Use case: authenticating returns the user and rejects every invalid case with one sentinel
|
||||
Given a fake KeyGenerator and keys that are unknown, expired, revoked, and owned by a missing or
|
||||
unverified user
|
||||
When AuthenticateAPIKey is called with each raw key
|
||||
Then every call returns apikey.ErrAPIKeyInvalid, asserted with errors.Is
|
||||
And a key expiring exactly at the clock's now is expired
|
||||
And a valid key returns the user from user.Repository
|
||||
|
||||
Scenario: Use case: a failing repository is not reported as an invalid key
|
||||
Given a repository whose GetByHashedKey returns an unexpected error
|
||||
When AuthenticateAPIKey is called
|
||||
Then the error is returned and errors.Is(err, apikey.ErrAPIKeyInvalid) is false
|
||||
And the middleware answers 500 "Internal server error"
|
||||
|
||||
Scenario: Use case: ResetPassword revokes the user's keys through the domain repository
|
||||
Given a fake apikey.Repository holding active keys of users 2 and 3, a fake clock at T, and a
|
||||
user.Repository fake with a valid reset code for user 2
|
||||
When ResetPasswordUseCase.ResetPassword succeeds for user 2
|
||||
Then the fake apikey.Repository received exactly one RevokeAllByUser(2, T)
|
||||
And user 3's keys are untouched
|
||||
|
||||
Scenario: Use case: ResetPassword does not call RevokeAllByUser when the reset fails
|
||||
Given a fake apikey.Repository and a user.Repository fake with a wrong or expired reset code
|
||||
When ResetPasswordUseCase.ResetPassword is called
|
||||
Then it returns the existing invalid or expired sentinel
|
||||
And RevokeAllByUser is never called
|
||||
|
||||
Scenario: Use case: a failure to revoke keys after a reset is reported
|
||||
Given a valid reset code and an apikey.Repository whose RevokeAllByUser returns an error
|
||||
When ResetPasswordUseCase.ResetPassword is called
|
||||
Then the use case returns that error
|
||||
And the password was already changed
|
||||
@@ -0,0 +1,171 @@
|
||||
# QA procedure: API keys
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once per test.
|
||||
2. Add in-memory fakes with unique type names across the package: `mockAPIKeyRepo`
|
||||
(implements `apikey.Repository` including `RevokeAllByUser`, with a `fail` switch per method such as
|
||||
`failNext("TouchLastUsed")`, a `touchCalls` counter and a `revokeAllCalls` log of `(userId, at)`) and
|
||||
`stubKeyGenerator` (deterministic raw/prefix/hash). Reuse `mockUserRepo`, `stubHasher` and the
|
||||
article, media and invite fakes.
|
||||
3. HTTP tests use the real SHA-256 generator, so key format and hashing are real. Unit-level tests
|
||||
use `stubKeyGenerator`.
|
||||
4. Build the handler stack from a `*api.UseCases` container holding fakes and mount
|
||||
`api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). It is the
|
||||
most faithful way to test the middleware order and the exclusions. If a nil use case breaks a
|
||||
handler constructor, fall back to a fresh `chi.NewRouter()` that mirrors `api/router.go`:
|
||||
`jwtauth.Verifier`, the key middleware, `jwtauth.Authenticator`, `RequireCurrentToken`,
|
||||
`RequireVerifiedUser` (and `RequireOwner` on the invite group) for the key-accepting routes
|
||||
(including `GET /auth/me`); `RequireSession` ahead of `jwtauth.Verifier` on `/api-key`,
|
||||
`POST /auth/verify`, `DELETE /auth/register` and `/system`; and the public auth routes with neither.
|
||||
The test token is `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||
5. Inject a fake clock (`now func() time.Time`) into the key use cases and `ResetPasswordUseCase`.
|
||||
6. Seed users: owner 1 "marta", admin 2 "dev", admin 3 "pat" (all verified), unverified 4 "quinn".
|
||||
7. Helpers: `cookieFor(userId, tokenVersion)` mints a jwt cookie with
|
||||
`token.Encode(map[string]any{"user_id": id, "token_version": v})`; `bearer(raw)` sets the header.
|
||||
Create real keys through `POST /v1/api-key` with the cookie so the shown-once value is genuine.
|
||||
|
||||
## POST /v1/api-key
|
||||
8. Admin 2 cookie, `{"name":"ci-deploy"}` -> `201`, `Cache-Control: no-store`, key set is exactly `id`,
|
||||
`name`, `prefix`, `key`, `created_at`, `expires_at`; `key` matches `^rcms_[A-Za-z0-9_-]{43}$`; prefix
|
||||
is `key[:12]`; stored `HashedKey` equals the generator's `Hash(key)`; `UserID` 2; the raw key is
|
||||
stored nowhere in the fake.
|
||||
9. Two creations give two different keys and ids.
|
||||
10. With `expires_at` 30 days ahead -> `201`, echoed instant.
|
||||
11. Invalid bodies (table): not-json -> `400` `apperr.BodyErrorMessage`; `{}`, `{"name":""}` -> `400`,
|
||||
body contains `'Name'` and `required`; 61-char name -> `400`, contains `max`; `expires_at`
|
||||
"yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`.
|
||||
Each: repository still empty.
|
||||
12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that
|
||||
key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite.
|
||||
13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
|
||||
stale `token_version` -> `401`. Each: repository empty.
|
||||
|
||||
## Shown once
|
||||
14. After case 8, `GET /v1/api-key` -> entry keys exactly `id`, `user_id`, `name`, `prefix`, `status`,
|
||||
`created_at`, `last_used_at`, `expires_at`, `revoked_at`; assert the response bytes contain neither
|
||||
the raw key nor the stored hash. Also assert the raw key and hash are absent from the `DELETE`
|
||||
response and from every error body in this file.
|
||||
15. There is no route that returns a key again: `GET /v1/api-key/{id}` -> `404` or `405` (whatever the
|
||||
router yields; assert not `200`).
|
||||
|
||||
## Authentication
|
||||
16. Table over `GET /v1/settings`, `GET /v1/user`, `PATCH /v1/user/me`, `POST /v1/article`,
|
||||
`PATCH /v1/article/{id}/publish`, `DELETE /v1/article/{id}`, `GET /v1/media`, `DELETE /v1/media/{id}`
|
||||
with only the Bearer key of admin 2 -> `200` each; the stored side effects (author, display name)
|
||||
belong to user 2.
|
||||
17. Public article routes: seed offline article 5, online article 6. Key -> list has both, `GET /5` is
|
||||
`200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article`
|
||||
-> `401` `apperr.UnauthorizedErrorMessage`.
|
||||
18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403`
|
||||
`apperr.OwnerRequiredErrorMessage`, no invite and no mail.
|
||||
19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the
|
||||
stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only):
|
||||
unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from
|
||||
the user fake; creator unverified (set `IsVerified=false` in the fake); `Bearer rcms_`; one
|
||||
character changed; the stored hash sent as the key. Assert each also leaves `touchCalls == 0`.
|
||||
Without an `rcms_` key the existing `jwtauth.Authenticator` response is unchanged: assert status `401`.
|
||||
20. A key whose `ExpiresAt` equals the clock's now is rejected; one second later than now is accepted.
|
||||
21. Cookie path unchanged: valid cookie -> `200`; valid jwt in `Authorization: Bearer` with no cookie ->
|
||||
`200`; stale `token_version` -> `401`; no credentials on a protected route -> `401`.
|
||||
22. Precedence: valid key of admin 3 plus an owner cookie on `POST /v1/user/invite` -> `403`
|
||||
(acts as admin 3); `Bearer rcms_unknown` plus an owner cookie on `GET /v1/settings` -> `401`.
|
||||
23. Role is read live: change the stored role of user 2 from admin to owner in the fake (test-only) and
|
||||
assert the key is now owner-capable, then back, and assert it is not. This pins "never more
|
||||
rights than the creator's current rights".
|
||||
24. `GET /v1/auth/me` with only admin 2's key -> `200`, body byte-equal to the body admin 2's cookie gets
|
||||
on the same route, describing user 2. `Bearer rcms_unknown` on `GET /v1/auth/me` -> `401`
|
||||
`apperr.UnauthorizedErrorMessage`.
|
||||
|
||||
## Session-only routes (RequireSession)
|
||||
25. Table, each with body `apperr.SessionRequiredErrorMessage`, status `403`, assert the underlying use
|
||||
cases (key repository, verify, cancel registration, update status/check/download/restart) were
|
||||
not invoked and the key repository is unchanged:
|
||||
- owner 1's active key on `POST /v1/api-key`, `GET /v1/api-key`, `DELETE /v1/api-key/1`,
|
||||
`POST /v1/auth/verify`, `DELETE /v1/auth/register`, `GET /v1/system/update`,
|
||||
`POST /v1/system/update/check`, `POST /v1/system/update/download`, `POST /v1/system/update/restart`;
|
||||
- a revoked key on `GET /v1/api-key`;
|
||||
- an unknown well-formed `rcms_...` key on `POST /v1/system/update/check`;
|
||||
- the bare string `rcms_` on `DELETE /v1/auth/register`.
|
||||
The 403 comes before any authentication, so assert the same 403 for an owner key and a garbage key.
|
||||
26. Session routes unchanged for sessions: owner cookie on `POST /v1/api-key`, `GET /v1/api-key`,
|
||||
`GET /v1/system/update`, `POST /v1/system/update/check` -> the same status and body as before this
|
||||
feature; no credentials -> status `401` (existing Authenticator); a jwt in `Authorization: Bearer`
|
||||
(not starting with `rcms_`) -> the same result as the cookie.
|
||||
27. Public auth routes with an owner key attached: `POST /v1/auth/login` with wrong credentials -> `403`
|
||||
`apperr.LogsErrorMessage`, no `jwt` Set-Cookie; `POST /v1/auth/password-reset/confirm` with a wrong
|
||||
code -> the same response as without the key, and the key is not rejected with
|
||||
`SessionRequiredErrorMessage`. The user's stored password hash and `TokenVersion` are unchanged.
|
||||
|
||||
## Password reset revokes keys
|
||||
28. Seed user 2 with active keys "a" and "b" (each `200` on `GET /v1/settings`), user 3 with key "c", and a
|
||||
reset code for user 2 (hash of "reset-code-1", unexpired). `POST /v1/auth/password-reset/confirm`
|
||||
`{"email":"dev@example.com","code":"reset-code-1","password":"newpassword1"}` -> `200`; then keys "a"
|
||||
and "b" get `401` `apperr.UnauthorizedErrorMessage` on `GET /v1/settings`.
|
||||
29. Same seed: key "c" still gets `200` and has no `revoked_at`.
|
||||
30. Same seed with the fake clock at T: log in as user 2 with the new password, `GET /v1/api-key` with the
|
||||
new cookie; "a" and "b" have status `revoked` and `revoked_at` equal to T.
|
||||
31. A key of user 2 already revoked at T0 keeps `revoked_at` T0 after a reset at a later T; the active
|
||||
key gets T.
|
||||
32. Failed confirms (table: wrong code; expired code; unknown email) -> status not `200`; key "a" has no
|
||||
`revoked_at`, still `200` on `GET /v1/settings`, and `revokeAllCalls` is empty.
|
||||
|
||||
## Listing
|
||||
33. Admin 2 sees only own keys; owner 1 sees all, newest first, each with its `user_id`.
|
||||
34. Status table: active never used (`active`, nulls), active used (timestamp kept), expired
|
||||
(`expired`), revoked (`revoked`, `revoked_at` set), expired-and-revoked (`revoked`).
|
||||
|
||||
## Revoke
|
||||
35. Own key -> `204`, empty body; the key then gets `401` on `GET /v1/settings`; the list shows `revoked`.
|
||||
36. Second `DELETE` -> `204`, `revoked_at` unchanged.
|
||||
37. Admin 2 revoking admin 3's key -> `404` `apperr.APIKeyNotFoundErrorMessage`; the key still works.
|
||||
Owner revoking it -> `204`; it then gets `401`.
|
||||
38. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`; unknown id -> `404`.
|
||||
39. Repository failure on `Revoke` -> `500` "Internal server error"; the key still works.
|
||||
|
||||
## last_used_at
|
||||
40. Use-case level with the fake clock: first auth at T writes T; at T+30s no write
|
||||
(`touchCalls` unchanged); at T+61s writes T+61s.
|
||||
41. `TouchLastUsed` failing -> the request still returns `200`.
|
||||
42. Rejected keys (case 19) never write.
|
||||
|
||||
## Rate limit
|
||||
43. Mirror a group using the new per-key limiter with a limit of 2 (an exported constructor taking max
|
||||
attempts and window): third request with key A -> `429`, body `apperr.TooManyRequestsErrorMessage`,
|
||||
`Retry-After` header; key B and a cookie request still `200`; invalid keys stay `401`, never `429`.
|
||||
44. Through `api.InitBackendRoutes`, send 121 requests with one key to `GET /v1/settings` -> the 121st is
|
||||
`429`, the first 120 are `200`.
|
||||
|
||||
## CORS
|
||||
45. Through `api.InitRoutes` with `CorsAllowedOrigins` `["https://app.example.com"]`: `OPTIONS /v1/article/1`
|
||||
with `Origin` and `Access-Control-Request-Method: PATCH` -> success, `Access-Control-Allow-Methods`
|
||||
includes `PATCH`; with `TRACE` it is not allowed.
|
||||
|
||||
## Use case level (no HTTP)
|
||||
46. `CreateAPIKey` with `stubKeyGenerator`: repository receives the hash and the prefix and the user id;
|
||||
the raw key appears only in the return value; a generator error -> returned, nothing stored; past
|
||||
expiry -> `errors.Is(err, apikey.ErrExpiryNotInFuture)`.
|
||||
47. `AuthenticateAPIKey`: each invalid case in 19 and 20 -> `errors.Is(err, apikey.ErrAPIKeyInvalid)`; a
|
||||
valid key returns the user; an unexpected repository error is returned and is not `ErrAPIKeyInvalid`.
|
||||
48. `ListAPIKeys` for admin and owner; `RevokeAPIKey` own, other's (`ErrAPIKeyNotFound` for admin),
|
||||
owner any, unknown (`ErrAPIKeyNotFound`).
|
||||
49. `ResetPasswordUseCase` built over `mockUserRepo`, `stubHasher`, `mockAPIKeyRepo` and a fake clock at T:
|
||||
a successful reset for user 2 -> exactly one `RevokeAllByUser(2, T)` and user 3's keys untouched; a
|
||||
wrong or expired code -> the existing sentinel and `revokeAllCalls` empty; `RevokeAllByUser`
|
||||
failing -> that error is returned (the password was already changed).
|
||||
50. `mockAPIKeyRepo.RevokeAllByUser` semantics: sets `RevokedAt` only on that user's not-yet-revoked
|
||||
keys.
|
||||
51. SHA-256 generator: raw matches the regex, `Hash(raw)` equals the generated hash, two generated keys
|
||||
differ, prefix is the first 12 characters.
|
||||
|
||||
## Manual verification (not exercised by the Go tests)
|
||||
- Real SQLite: start `task dev:api`, confirm `AutoMigrate` creates the api_keys table with a unique index
|
||||
on the hashed key, create a key, restart, and check it still authenticates.
|
||||
- With `curl`, run `Authorization: Bearer <key>` against a running server for an article create, a media
|
||||
upload, an owner invite and `GET /v1/auth/me`, and confirm `PATCH` works from a browser origin listed
|
||||
in `CORS_ALLOWED_ORIGINS`.
|
||||
- Confirm the full key is shown only in the creation response and appears in no server log
|
||||
(`ENVIRONMENT=development` enables httplog).
|
||||
- A real reverse proxy: confirm `Authorization` is forwarded and `Retry-After` reaches the client.
|
||||
- A real password reset by email: confirm the keys stop working after the link is used.
|
||||
Reference in new issue
Block a user