feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations, casts and validation, attachments and transactions (lagoon.Transaction, lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase) - docs/services: configuration, events, routing with auth groups, rate limiting, authentication, the OAuth server, mail and localization - runnable Examples for lagoon, attach, compass, surf, wire, bouncer, wristband, postcard, phrasebook and festival; lagoon TestDocs* regions run on the package's Postgres harness through DocsDB - 15 new required pages
This commit is contained in:
128
docs/services/authentication.md
Normal file
128
docs/services/authentication.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt.
|
||||
section: services
|
||||
order: 50
|
||||
---
|
||||
# Authentication
|
||||
|
||||
WinterCMS reads the current user through the `Auth` and `BackendAuth` facades, and API plugins add a JWT layer on top. SummerCMS has no facades: [bouncer](../../modules/bouncer/README.md) turns a request into a `bouncer.Principal` through a guard, stores it on the request context, and issues and checks the tokens. The frontend user model and its login endpoints belong to the application's user plugin; bouncer supplies the building blocks.
|
||||
|
||||
## Tokens
|
||||
|
||||
bouncer issues HS256 JSON Web Tokens for two audiences: frontend users (`bouncer.AudienceUser`) and admins (`bouncer.AudienceBackend`). `bouncer.Mint` signs a frontend token for a subject, the user ID as a string, and returns the token and its random `jti`. `bouncer.MintAudience` signs one for any audience.
|
||||
|
||||
`bouncer.VerifyClaims` checks the signature with HS256 pinned, requires `exp` and `sub`, and returns the claims; `bouncer.Verify` returns only the subject. Both accept frontend tokens, including older tokens without an audience claim. `bouncer.VerifyClaimsAudience` requires the audience you name, so a frontend token never passes an admin check and the other way round:
|
||||
|
||||
```go src=modules/bouncer/example_test.go#ExampleMint
|
||||
const issuer = "http://127.0.0.1:8080/api/login"
|
||||
token, _, err := bouncer.Mint(testSecret, "42", issuer, time.Hour)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
sub, iat, exp, _, err := bouncer.VerifyClaims(token, testSecret)
|
||||
fmt.Println(sub, exp.Sub(iat), err)
|
||||
|
||||
// A frontend token never passes a backend check, and a wrong secret fails.
|
||||
_, _, _, _, err = bouncer.VerifyClaimsAudience(token, testSecret, bouncer.AudienceBackend)
|
||||
fmt.Println(err != nil)
|
||||
_, err = bouncer.Verify(token, "another-secret-with-at-least-32-bytes")
|
||||
fmt.Println(err != nil)
|
||||
|
||||
// Refresh reissues the token and blacklists the old jti after the grace.
|
||||
bl := bouncer.NewMemoryBlacklist()
|
||||
fresh, err := bouncer.Refresh(testSecret, token, 14*24*time.Hour, bl, 0, issuer)
|
||||
fmt.Println(fresh != token, err)
|
||||
_, _, _, jti, _ := bouncer.VerifyClaims(token, testSecret)
|
||||
revoked, _ := bl.IsBlacklisted(context.Background(), jti)
|
||||
fmt.Println(revoked)
|
||||
// Output:
|
||||
// 42 1h0m0s <nil>
|
||||
// true
|
||||
// true
|
||||
// true <nil>
|
||||
// true
|
||||
```
|
||||
|
||||
The secret in these examples is a test value. In an application, read the signing secret from configuration set through an environment variable, use at least 32 random bytes and never commit it.
|
||||
|
||||
## Refreshing and revoking
|
||||
|
||||
`bouncer.Refresh` reissues a token while its `iat` is inside the refresh window, even when it has expired, and blacklists the old `jti` after a grace period, so requests already in flight with the old token still succeed. `bouncer.RefreshAudienceFor` also reloads the user and refuses one who was deleted, or whose tokens were issued before `bouncer.Principal.TokensValidAfter`, with `bouncer.ErrSubjectRejected`. The rules match the PHP jwt-auth library, so tokens issued by a WinterCMS application keep working after a port.
|
||||
|
||||
Revoked token IDs are kept in a `bouncer.BlacklistStore`:
|
||||
|
||||
- `bouncer.NewMemoryBlacklist` keeps them in the process, for tests.
|
||||
- `bouncer.NewPostgresBlacklist` keeps them in a table you name, with `jti`, `expires_at` and `valid_until` columns. It rejects table names that are not plain identifiers.
|
||||
|
||||
Logging out is blacklisting the token's `jti`. Setting a user's `TokensValidAfter` to now revokes all their tokens at once, for example after a password change.
|
||||
|
||||
## Guards and middleware
|
||||
|
||||
A guard implements `bouncer.Guard`: it turns a request into a principal or an error. `bouncer.NewJWTGuard` is the frontend guard. It reads the bearer token, then any cookie names you give it, verifies the token, checks the blacklist and the user's cutoff, and loads the user through your `bouncer.UserProvider`. On failure it answers 401 with `{"error":true,"message":...}`. `bouncer.NewBackendJWTGuard` is the same for the admin audience.
|
||||
|
||||
Register guards in a `bouncer.Registry` under a name, and turn one into middleware with `bouncer.Registry.Middleware`. The middleware stores the principal on the context, where handlers read it with `bouncer.User`:
|
||||
|
||||
```go src=modules/bouncer/example_test.go#ExampleNewJWTGuard
|
||||
guards := bouncer.NewRegistry()
|
||||
guard := bouncer.NewJWTGuard(testSecret, users{}, bouncer.NewMemoryBlacklist(), "token")
|
||||
if err := guards.Register("acme.blog", "acme.auth", guard); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
auth, err := guards.Middleware("acme.auth")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
me := auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := bouncer.User(r.Context())
|
||||
fmt.Fprintf(w, "user %d, locale %s", user.ID, user.PreferredLocale)
|
||||
}))
|
||||
|
||||
token, _, _ := bouncer.Mint(testSecret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
|
||||
for _, set := range []func(*http.Request){
|
||||
func(r *http.Request) {},
|
||||
func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) },
|
||||
func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "token", Value: token}) },
|
||||
} {
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
set(req)
|
||||
rec := httptest.NewRecorder()
|
||||
me.ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
// Output:
|
||||
// 401 {"error":true,"message":"Token not provided"}
|
||||
// 200 user 42, locale pl
|
||||
// 200 user 42, locale pl
|
||||
```
|
||||
|
||||
To protect routes, return that middleware from `pact.HasMiddleware` under a name and put the name on a route group. [Routing](routing.md) shows the complete auth group. A guard that does not implement `bouncer.UnauthorizedWriter` lets an unauthenticated request through without a principal, for routes that behave differently for guests.
|
||||
|
||||
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
|
||||
|
||||
## Passwords
|
||||
|
||||
`bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working:
|
||||
|
||||
```go src=modules/bouncer/example_test.go#ExampleHashPassword
|
||||
hash, err := bouncer.HashPassword(10, "correct horse battery staple")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(bouncer.CheckPassword(hash, "correct horse battery staple"))
|
||||
fmt.Println(bouncer.CheckPassword(hash, "wrong"))
|
||||
// After raising the configured cost, rehash on the next successful login.
|
||||
fmt.Println(bouncer.NeedsRehash(hash, 12))
|
||||
// Output:
|
||||
// true
|
||||
// false
|
||||
// true
|
||||
```
|
||||
|
||||
Rehash a password on the next successful login when `bouncer.NeedsRehash` reports true.
|
||||
|
||||
Admin sign-in, admin permissions and the admin user commands are covered in the Backend section.
|
||||
Reference in New Issue
Block a user