This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
package auth
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/iceBear67/simplepages/internal/store"
)
// BootstrapKeyName is the name given to the first-run admin key, so an operator
// listing keys can tell it apart from ones they minted themselves.
const BootstrapKeyName = "bootstrap"
// EnsureAdminKey mints an admin key and writes its token to tokenPath when the
// server has no usable admin key at all, and reports whether it did.
//
// This is the only place the server ever writes a token to disk, and it exists
// because a fresh install would otherwise have no way to authenticate the call
// that creates the first key. The file is 0600 and the log line tells the
// operator to delete it once they have copied the token out.
//
// "Usable" excludes revoked and expired keys, so an installation whose only
// admin key was revoked recovers by restarting rather than by hand-editing the
// database.
func EnsureAdminKey(ctx context.Context, db *store.DB, tokenPath string, log *slog.Logger) (bool, error) {
n, err := db.CountUsableAdminKeys(ctx)
if err != nil {
return false, fmt.Errorf("count admin keys: %w", err)
}
if n > 0 {
// Nothing to mint. If a token file is still lying around from an earlier
// bootstrap, say so: it is a live credential until that key is revoked.
if _, err := os.Lstat(tokenPath); err == nil && log != nil {
log.Warn("bootstrap token file still present; delete it once the token is stored elsewhere",
"path", tokenPath)
}
return false, nil
}
token, keyID, hash, err := Mint()
if err != nil {
return false, err
}
if err := writeTokenFile(tokenPath, token); err != nil {
return false, err
}
k := &store.APIKey{
ID: keyID,
SecretHash: hash[:],
Scope: store.ScopeAdmin,
Name: BootstrapKeyName,
}
if err := db.CreateKey(ctx, k); err != nil {
// The file names a key that does not exist. Remove it rather than leave
// an operator holding a token that will never authenticate.
_ = os.Remove(tokenPath)
return false, fmt.Errorf("create bootstrap key: %w", err)
}
if log != nil {
// The key id is the public half of the token and is safe to log; the
// token itself is not, which is the whole reason for the file.
log.Warn("no admin key found; minted a bootstrap admin key",
"key_id", keyID, "path", tokenPath,
"action", "read the token, then delete this file")
}
return true, nil
}
// writeTokenFile writes the token with 0600 permissions.
//
// It writes to a temporary file in the same directory and renames it into
// place. That is not for atomicity — nothing reads this concurrently — but
// because rename replaces the destination without following it. Opening the
// path directly would follow a symlink someone had planted there and write a
// live credential wherever it pointed.
func writeTokenFile(path, token string) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".bootstrap-token-*")
if err != nil {
return fmt.Errorf("create bootstrap token file: %w", err)
}
tmp := f.Name()
defer func() {
f.Close()
os.Remove(tmp) // no-op once the rename has succeeded
}()
// CreateTemp already makes the file 0600, but say so explicitly: this is the
// property that matters and it should not depend on a documented default.
if err := f.Chmod(0o600); err != nil {
return fmt.Errorf("chmod bootstrap token file: %w", err)
}
if _, err := f.WriteString(token + "\n"); err != nil {
return fmt.Errorf("write bootstrap token: %w", err)
}
if err := f.Sync(); err != nil {
return fmt.Errorf("sync bootstrap token: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close bootstrap token: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("install bootstrap token: %w", err)
}
return nil
}
+285
View File
@@ -0,0 +1,285 @@
package auth
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/internal/store"
)
// bootstrapEnv is a database, a token path in a directory of its own, and a log
// buffer to assert against.
type bootstrapEnv struct {
db *store.DB
dir string
log *slog.Logger
buf *bytes.Buffer
}
func newBootstrapEnv(t *testing.T) *bootstrapEnv {
t.Helper()
var buf bytes.Buffer
return &bootstrapEnv{
db: testStore(t),
dir: t.TempDir(),
log: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})),
buf: &buf,
}
}
func (e *bootstrapEnv) path() string { return filepath.Join(e.dir, "bootstrap-token") }
func (e *bootstrapEnv) token(t *testing.T) string {
t.Helper()
raw, err := os.ReadFile(e.path())
if err != nil {
t.Fatalf("read token file: %v", err)
}
return strings.TrimSpace(string(raw))
}
func TestEnsureAdminKeyMintsOnEmptyDatabase(t *testing.T) {
ctx := context.Background()
e := newBootstrapEnv(t)
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
if err != nil {
t.Fatalf("EnsureAdminKey: %v", err)
}
if !minted {
t.Fatal("minted = false on an empty database")
}
// The token in the file is the credential: it must actually authenticate.
v := newVerifier(t, e.db)
id, err := v.Verify(ctx, e.token(t))
if err != nil {
t.Fatalf("the bootstrap token does not authenticate: %v", err)
}
if !id.IsAdmin() {
t.Errorf("scope = %q, want admin", id.Scope)
}
if id.Name != BootstrapKeyName {
t.Errorf("name = %q, want %q", id.Name, BootstrapKeyName)
}
// The operator is told where the file is and that it must be removed, and
// the token itself never reaches the log — the file exists precisely so it
// does not have to.
logged := e.buf.String()
if !strings.Contains(logged, e.path()) {
t.Errorf("log does not name the token file: %s", logged)
}
if strings.Contains(logged, e.token(t)) {
t.Fatal("the bootstrap token was written to the log")
}
if !strings.Contains(logged, id.KeyID) {
t.Errorf("log does not name the key id, which is the safe half: %s", logged)
}
}
// The file holds a live admin credential. Anything wider than 0600 hands it to
// every account on the host.
func TestBootstrapTokenFileIsPrivate(t *testing.T) {
ctx := context.Background()
e := newBootstrapEnv(t)
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
t.Fatal(err)
}
fi, err := os.Lstat(e.path())
if err != nil {
t.Fatal(err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("mode = %#o, want 0600", perm)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("the token path is a symlink")
}
}
func TestEnsureAdminKeyIsIdempotent(t *testing.T) {
ctx := context.Background()
e := newBootstrapEnv(t)
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
t.Fatal(err)
}
first := e.token(t)
// A restart must not mint a second admin key, and must not overwrite the
// token the operator has not yet collected.
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
if err != nil {
t.Fatal(err)
}
if minted {
t.Error("minted a second bootstrap key")
}
if got := e.token(t); got != first {
t.Error("the token file was rewritten on the second run")
}
n, err := e.db.CountUsableAdminKeys(ctx)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("usable admin keys = %d, want 1", n)
}
// A leftover file is a live credential; the operator gets told about it.
if !strings.Contains(e.buf.String(), "still present") {
t.Errorf("no warning about the leftover token file: %s", e.buf.String())
}
}
// A revoked or expired admin key leaves the installation locked out, so
// restarting must mint a fresh one rather than counting the dead key.
func TestEnsureAdminKeyRecoversFromUnusableKeys(t *testing.T) {
ctx := context.Background()
t.Run("revoked", func(t *testing.T) {
e := newBootstrapEnv(t)
_, keyID := mintInto(t, e.db, store.ScopeAdmin, nil)
if err := e.db.RevokeKey(ctx, keyID); err != nil {
t.Fatal(err)
}
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
if err != nil {
t.Fatal(err)
}
if !minted {
t.Fatal("minted = false with only a revoked admin key")
}
})
t.Run("expired", func(t *testing.T) {
e := newBootstrapEnv(t)
token, _, hash, err := Mint()
if err != nil {
t.Fatal(err)
}
past := time.Now().Add(-time.Hour)
k := &store.APIKey{ID: keyIDOf(t, token), SecretHash: hash[:],
Scope: store.ScopeAdmin, Name: "old", ExpiresAt: &past}
if err := e.db.CreateKey(ctx, k); err != nil {
t.Fatal(err)
}
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
if err != nil {
t.Fatal(err)
}
if !minted {
t.Fatal("minted = false with only an expired admin key")
}
})
t.Run("project keys do not count", func(t *testing.T) {
e := newBootstrapEnv(t)
id := createProjectRow(t, e.db, "demo")
mintInto(t, e.db, store.ScopeProject, &id)
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
if err != nil {
t.Fatal(err)
}
if !minted {
t.Fatal("minted = false with only a project key")
}
})
}
// The token path is attacker-controllable on a host where the data directory is
// created before the server runs. Writing through a planted symlink would put a
// live admin credential wherever it pointed — /etc/cron.d, another user's
// ~/.ssh, a world-readable log. The rename-into-place replaces the link instead
// of following it.
func TestBootstrapTokenDoesNotFollowASymlink(t *testing.T) {
ctx := context.Background()
e := newBootstrapEnv(t)
target := filepath.Join(t.TempDir(), "victim")
if err := os.WriteFile(target, []byte("original\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, e.path()); err != nil {
t.Fatal(err)
}
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
t.Fatalf("EnsureAdminKey: %v", err)
}
victim, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(victim) != "original\n" {
t.Fatalf("the symlink target was overwritten with %q", victim)
}
fi, err := os.Lstat(e.path())
if err != nil {
t.Fatal(err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Fatal("the token path is still a symlink; the token went somewhere else")
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("mode = %#o, want 0600", perm)
}
if !strings.HasPrefix(e.token(t), Prefix+"_") {
t.Errorf("the token file does not hold a token")
}
}
// A directory in the way must fail loudly rather than leave the server running
// with an admin key nobody can use.
func TestBootstrapTokenReportsAnUnwritablePath(t *testing.T) {
ctx := context.Background()
e := newBootstrapEnv(t)
blocked := filepath.Join(e.dir, "blocked")
if err := os.Mkdir(blocked, 0o755); err != nil {
t.Fatal(err)
}
if _, err := EnsureAdminKey(ctx, e.db, blocked, e.log); err == nil {
t.Fatal("EnsureAdminKey = nil, want an error when the token cannot be stored")
}
// And no key was created: a key whose token was never delivered is just a
// row nobody can authenticate with.
n, err := e.db.CountUsableAdminKeys(ctx)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("usable admin keys = %d, want 0", n)
}
}
// keyIDOf recovers the public half of a token, which is all a test needs to
// store the row.
func keyIDOf(t *testing.T, token string) string {
t.Helper()
keyID, _, err := Parse(token)
if err != nil {
t.Fatalf("Parse: %v", err)
}
return keyID
}
// createProjectRow inserts a project so a project-scoped key has something to
// point at, and returns its id.
func createProjectRow(t *testing.T, db *store.DB, name string) int64 {
t.Helper()
p := store.DefaultProject(name)
if err := db.CreateProject(context.Background(), p); err != nil {
t.Fatalf("CreateProject: %v", err)
}
return p.ID
}
+216
View File
@@ -0,0 +1,216 @@
package auth
import (
"context"
"errors"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
type ctxKey int
const identityKey ctxKey = iota
// IdentityFrom returns the identity established by Authenticate.
//
// A handler mounted behind Authenticate can treat a false result as a
// programming error: the middleware answers 401 itself and never calls through
// without an identity.
func IdentityFrom(ctx context.Context) (*Identity, bool) {
id, ok := ctx.Value(identityKey).(*Identity)
return id, ok
}
// ContextWithIdentity is used by tests and by handlers that authenticate out of
// band; ordinary request handling gets its identity from Authenticate.
func ContextWithIdentity(ctx context.Context, id *Identity) context.Context {
return context.WithValue(ctx, identityKey, id)
}
// errUnauthorized is the single response every authentication failure produces.
// Distinguishing "no such key" from "wrong secret" from "revoked" would tell an
// unauthenticated caller which key ids are real.
func errUnauthorized() *api.Error {
return api.Errorf(api.CodeUnauthorized, "missing or invalid API token")
}
// Middleware carries the collaborators the auth handlers need.
type Middleware struct {
V *Verifier
Limiter *Limiter
Trusted []netip.Prefix
Log *slog.Logger
}
// Authenticate requires a valid bearer token and puts the identity in the
// request context.
//
// The token is read only from the Authorization header, never from a query
// parameter: query strings land in proxy access logs, browser history and
// Referer headers, and a credential that ends up there is a credential leaked.
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
m.reject(w, r, errors.New("auth: no bearer token"))
return
}
client := m.clientKey(r)
if !m.Limiter.Allow(client) {
if d := m.Limiter.RetryAfter(client); d > 0 {
w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds())))
}
httpx.WriteError(w, r, m.Log, api.Errorf(api.CodeRateLimited,
"too many failed authentication attempts; slow down"))
return
}
id, err := m.V.Verify(r.Context(), token)
if err != nil {
m.Limiter.Fail(client)
m.reject(w, r, err)
return
}
// The key id is the public half of the token and is safe to log; the
// secret never leaves this function.
httpx.LogAttr(r.Context(), "key_id", id.KeyID)
httpx.LogAttr(r.Context(), "scope", string(id.Scope))
next.ServeHTTP(w, r.WithContext(ContextWithIdentity(r.Context(), id)))
})
}
// reject logs why authentication failed and tells the client only that it did.
func (m *Middleware) reject(w http.ResponseWriter, r *http.Request, cause error) {
if m.Log != nil {
// cause is one of this package's sentinels or a store error. None of
// them embed the token, which is what makes it safe to log at all.
m.Log.Debug("authentication failed",
"reason", cause,
"req_id", httpx.RequestIDFrom(r.Context()),
"path", r.URL.Path)
}
w.Header().Set("WWW-Authenticate", `Bearer realm="pages"`)
httpx.WriteError(w, r, m.Log, errUnauthorized())
}
// clientKey identifies the caller for rate limiting.
func (m *Middleware) clientKey(r *http.Request) string {
if addr, ok := httpx.ClientIP(r, m.Trusted); ok {
return addr.String()
}
return r.RemoteAddr
}
// bearerToken extracts the credential from the Authorization header. The scheme
// comparison is case-insensitive per RFC 7235; the token itself is not touched.
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if h == "" {
return "", false
}
scheme, token, ok := strings.Cut(h, " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
return "", false
}
token = strings.TrimSpace(token)
if token == "" {
return "", false
}
return token, true
}
// RequireAdmin rejects identities that are not admin-scoped.
func RequireAdmin(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := IdentityFrom(r.Context())
if !ok {
httpx.WriteError(w, r, log, errUnauthorized())
return
}
if !id.IsAdmin() {
httpx.WriteError(w, r, log, api.Errorf(api.CodeForbidden,
"this operation requires an admin key"))
return
}
next.ServeHTTP(w, r)
})
}
}
// ProjectResolver maps a project name from the URL to its row id.
//
// It is an interface rather than a concrete type so this package does not
// depend on the site registry, which does not exist until the serving layer is
// wired up, and so tests can supply a two-line fake.
type ProjectResolver interface {
ResolveProject(ctx context.Context, name string) (int64, error)
}
// ResolverFunc adapts a function to ProjectResolver.
type ResolverFunc func(ctx context.Context, name string) (int64, error)
func (f ResolverFunc) ResolveProject(ctx context.Context, name string) (int64, error) {
return f(ctx, name)
}
// RequireProject allows admins through and otherwise requires the caller's key
// to belong to the project named by the {pathValue} URL wildcard.
//
// The comparison is on resolved row ids, never on the name string. Comparing
// names would make the trust boundary depend on every handler normalising the
// same way, and would break the moment two names can resolve to one project.
func RequireProject(pathValue string, r ProjectResolver, log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
id, ok := IdentityFrom(req.Context())
if !ok {
httpx.WriteError(w, req, log, errUnauthorized())
return
}
name := req.PathValue(pathValue)
if name == "" {
httpx.WriteError(w, req, log, api.Errorf(api.CodeBadRequest, "missing project name"))
return
}
projectID, err := r.ResolveProject(req.Context(), name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// A project-scoped key must not be able to probe which
// project names exist, so an unknown name looks the same as
// someone else's project.
if !id.IsAdmin() {
httpx.WriteError(w, req, log, forbiddenProject())
return
}
httpx.WriteError(w, req, log,
api.Errorf(api.CodeNotFound, "no such project: %s", name))
return
}
httpx.WriteError(w, req, log, err)
return
}
if !id.Owns(projectID) {
httpx.WriteError(w, req, log, forbiddenProject())
return
}
httpx.LogAttr(req.Context(), "project", name)
next.ServeHTTP(w, req)
})
}
}
func forbiddenProject() *api.Error {
return api.Errorf(api.CodeForbidden, "this key does not have access to that project")
}
+407
View File
@@ -0,0 +1,407 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
// okHandler records that the request got past the middleware.
func okHandler(reached *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if reached != nil {
*reached = true
}
w.WriteHeader(http.StatusNoContent)
})
}
func errorCode(t *testing.T, body []byte) api.Code {
t.Helper()
var env api.ErrorEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("response is not an error envelope: %v (%s)", err, body)
}
if env.Error.Code == "" {
t.Fatalf("envelope has no error code: %s", body)
}
return env.Error.Code
}
func newMiddleware(t *testing.T, db *store.DB, logTo *bytes.Buffer) *Middleware {
t.Helper()
var h slog.Handler = slog.NewTextHandler(logTo, &slog.HandlerOptions{Level: slog.LevelDebug})
log := slog.New(h)
return &Middleware{
V: NewVerifier(db, log, DefaultCacheTTL),
Limiter: NewLimiter(5, time.Minute, 128),
Trusted: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
Log: log,
}
}
func TestAuthenticateAcceptsValidToken(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
var gotID *Identity
h := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := IdentityFrom(r.Context())
if !ok {
t.Error("no identity in context behind Authenticate")
}
gotID = id
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body)
}
if gotID == nil || gotID.KeyID != keyID {
t.Errorf("identity = %+v, want key %s", gotID, keyID)
}
}
func TestAuthenticateRejects(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
_, secret, err := Parse(token)
if err != nil {
t.Fatal(err)
}
unknown, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
header string
}{
{"no header", ""},
{"empty bearer", "Bearer "},
{"wrong scheme", "Basic " + token},
{"token without scheme", token},
{"malformed token", "Bearer not-a-token"},
{"unknown key", "Bearer " + unknown},
{"truncated secret", "Bearer " + Prefix + "_" + keyID + "_" + secret[:42]},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
h := m.Authenticate(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if reached {
t.Error("request reached the handler")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401: %s", rec.Code, rec.Body)
}
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeUnauthorized {
t.Errorf("code = %q, want %q", got, api.CodeUnauthorized)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
t.Errorf("WWW-Authenticate = %q", got)
}
// The client must not learn which check failed.
body := rec.Body.String()
for _, leak := range []string{"revoked", "expired", "unknown key", "secret mismatch"} {
if strings.Contains(strings.ToLower(body), leak) {
t.Errorf("response distinguishes the failure reason (%q): %s", leak, body)
}
}
})
}
}
// The scheme is case-insensitive per RFC 7235, and some CI clients send "bearer".
func TestAuthenticateAcceptsAnyCaseScheme(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
for _, scheme := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} {
reached := false
h := m.Authenticate(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Authorization", scheme+" "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !reached {
t.Errorf("scheme %q rejected: %d %s", scheme, rec.Code, rec.Body)
}
}
}
// A token in the query string ends up in proxy logs and browser history, so it
// must never be accepted as a credential.
func TestTokenInQueryStringIsNotAccepted(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
reached := false
h := m.Authenticate(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?token="+token+"&access_token="+token, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if reached {
t.Fatal("a query-string token authenticated the request")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// The load-bearing one: nothing this middleware logs may contain the secret.
func TestAuthLogsNeverContainCredentials(t *testing.T) {
db := testStore(t)
var logBuf bytes.Buffer
m := newMiddleware(t, db, &logBuf)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
_, secret, err := Parse(token)
if err != nil {
t.Fatal(err)
}
handler := httpx.Chain(
m.Authenticate(okHandler(nil)),
httpx.WithRequestID(m.Trusted),
httpx.AccessLog(m.Log, m.Trusted),
httpx.Recover(m.Log),
)
// A successful request, a wrong-secret request, and a garbage request: the
// three paths that each touch the token.
for _, hdr := range []string{
"Bearer " + token,
"Bearer " + Prefix + "_" + keyID + "_" + strings.Repeat("z", 43),
"Bearer " + token + "trailing",
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
req.Header.Set("Authorization", hdr)
req.RemoteAddr = "10.1.2.3:5555"
handler.ServeHTTP(httptest.NewRecorder(), req)
}
out := logBuf.String()
if out == "" {
t.Fatal("nothing was logged; the test would pass vacuously")
}
for _, forbidden := range []string{secret, token, "Bearer", "Authorization"} {
if strings.Contains(out, forbidden) {
t.Errorf("log contains %q:\n%s", forbidden, out)
}
}
// The public half is supposed to be there — otherwise an operator cannot
// tell which key made a request.
if !strings.Contains(out, keyID) {
t.Errorf("log does not record the key id:\n%s", out)
}
}
func TestAuthenticateRateLimitsFailures(t *testing.T) {
db := testStore(t)
m := newMiddleware(t, db, &bytes.Buffer{})
m.Limiter = NewLimiter(3, time.Minute, 32)
bad, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
good, _ := mintInto(t, db, store.ScopeAdmin, nil)
send := func(token, remote string) *httptest.ResponseRecorder {
h := m.Authenticate(okHandler(nil))
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = remote
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
if got := send(bad, "10.1.2.3:5555").Code; got != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i, got)
}
}
rec := send(bad, "10.1.2.3:5555")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429: %s", rec.Code, rec.Body)
}
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeRateLimited {
t.Errorf("code = %q, want %q", got, api.CodeRateLimited)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("429 without a Retry-After header")
}
// A different address is unaffected...
if got := send(bad, "10.9.9.9:5555").Code; got != http.StatusUnauthorized {
t.Errorf("unrelated client got %d, want 401", got)
}
// ...and a client that had never failed can still authenticate.
if got := send(good, "10.8.8.8:5555").Code; got != http.StatusNoContent {
t.Errorf("valid token from a clean client got %d, want 204", got)
}
}
func TestRequireAdmin(t *testing.T) {
db := testStore(t)
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
p := store.DefaultProject("demo")
if err := db.CreateProject(context.Background(), p); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
identity *Identity
wantStatus int
}{
{"admin", &Identity{KeyID: "a", Scope: store.ScopeAdmin}, http.StatusNoContent},
{"project", &Identity{KeyID: "b", Scope: store.ScopeProject, ProjectID: &p.ID}, http.StatusForbidden},
{"none", nil, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
h := RequireAdmin(log)(okHandler(&reached))
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
if tc.identity != nil {
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
}
if reached != (tc.wantStatus == http.StatusNoContent) {
t.Errorf("handler reached = %v", reached)
}
})
}
}
func TestRequireProject(t *testing.T) {
db := testStore(t)
ctx := context.Background()
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
mine := store.DefaultProject("mine")
theirs := store.DefaultProject("theirs")
if err := db.CreateProject(ctx, mine); err != nil {
t.Fatal(err)
}
if err := db.CreateProject(ctx, theirs); err != nil {
t.Fatal(err)
}
resolver := ResolverFunc(func(ctx context.Context, name string) (int64, error) {
p, err := db.ProjectByName(ctx, name)
if err != nil {
return 0, err
}
return p.ID, nil
})
admin := &Identity{KeyID: "admin00000000000", Scope: store.ScopeAdmin}
owner := &Identity{KeyID: "owner00000000000", Scope: store.ScopeProject, ProjectID: &mine.ID}
cases := []struct {
name string
identity *Identity
project string
wantStatus int
}{
{"owner on own project", owner, "mine", http.StatusNoContent},
{"owner on another project", owner, "theirs", http.StatusForbidden},
{"admin on any project", admin, "theirs", http.StatusNoContent},
{"admin on unknown project", admin, "ghost", http.StatusNotFound},
// An unknown name must look exactly like someone else's project to a
// project-scoped key, or the API becomes a project-name oracle.
{"owner on unknown project", owner, "ghost", http.StatusForbidden},
{"no identity", nil, "mine", http.StatusUnauthorized},
{"missing name", owner, "", http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reached := false
mux := http.NewServeMux()
mux.Handle("GET /api/v1/projects/{name}", RequireProject("name", resolver, log)(okHandler(&reached)))
// The "missing name" case cannot be produced through the mux, so it
// exercises the handler directly.
var h http.Handler = mux
target := "/api/v1/projects/" + tc.project
if tc.project == "" {
h = RequireProject("name", resolver, log)(okHandler(&reached))
target = "/api/v1/projects/"
}
req := httptest.NewRequest(http.MethodGet, target, nil)
if tc.identity != nil {
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
}
if reached != (tc.wantStatus == http.StatusNoContent) {
t.Errorf("handler reached = %v", reached)
}
})
}
}
// Ownership is decided on row ids. A project key must not gain access to a
// project just because a name resolves to it.
func TestOwnsComparesIDsNotNames(t *testing.T) {
one := int64(1)
two := int64(2)
cases := []struct {
name string
id *Identity
ask int64
want bool
}{
{"admin owns anything", &Identity{Scope: store.ScopeAdmin}, 42, true},
{"project owns itself", &Identity{Scope: store.ScopeProject, ProjectID: &one}, 1, true},
{"project does not own another", &Identity{Scope: store.ScopeProject, ProjectID: &two}, 1, false},
{"project key without a project owns nothing", &Identity{Scope: store.ScopeProject}, 1, false},
{"nil identity owns nothing", nil, 1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.id.Owns(tc.ask); got != tc.want {
t.Errorf("Owns(%d) = %v, want %v", tc.ask, got, tc.want)
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"sync"
"time"
"github.com/iceBear67/simplepages/internal/cache"
)
// Limiter throttles clients that keep presenting bad credentials.
//
// It exists for CPU, not for guessing. An 80-bit key id plus a 256-bit secret
// cannot be brute forced, so the realistic attack is not "eventually guess a
// token" but "make the server parse, look up and hash forever". Only failed
// attempts consume budget, so a busy CI runner pushing hundreds of valid
// requests a second is never touched.
//
// The bucket map is an LRU with a hard cap, because its keys are whatever
// addresses show up: an unbounded map keyed by attacker-chosen input would turn
// the defence into a memory exhaustion vector of its own.
type Limiter struct {
buckets *cache.Cache[string, *bucket]
burst float64
refill float64 // tokens per second
now func() time.Time // swapped in tests
}
type bucket struct {
mu sync.Mutex
tokens float64
last time.Time
}
// NewLimiter allows burst consecutive failures per client, refilling to full
// over period, and tracks at most maxClients addresses.
func NewLimiter(burst int, period time.Duration, maxClients int) *Limiter {
if burst < 1 {
burst = 1
}
if period <= 0 {
period = time.Minute
}
// Idle buckets are dropped after twice the refill period: by then a bucket
// has refilled completely, so forgetting it and recreating it full are the
// same thing.
return &Limiter{
buckets: cache.New[string, *bucket](maxClients, 2*period),
burst: float64(burst),
refill: float64(burst) / period.Seconds(),
now: time.Now,
}
}
// Allow reports whether client has any budget left. It does not consume any:
// a request that turns out to authenticate correctly should cost nothing.
func (l *Limiter) Allow(client string) bool {
if l == nil {
return true
}
b := l.bucket(client)
b.mu.Lock()
defer b.mu.Unlock()
l.refillLocked(b)
return b.tokens >= 1
}
// Fail records one failed attempt for client.
func (l *Limiter) Fail(client string) {
if l == nil {
return
}
b := l.bucket(client)
b.mu.Lock()
defer b.mu.Unlock()
l.refillLocked(b)
if b.tokens >= 1 {
b.tokens--
} else {
b.tokens = 0
}
}
// RetryAfter estimates how long client must wait for one token, for the
// Retry-After header. It rounds up to whole seconds, and never returns zero
// while the client is actually throttled.
func (l *Limiter) RetryAfter(client string) time.Duration {
if l == nil {
return 0
}
b := l.bucket(client)
b.mu.Lock()
defer b.mu.Unlock()
l.refillLocked(b)
if b.tokens >= 1 {
return 0
}
need := 1 - b.tokens
d := time.Duration(need / l.refill * float64(time.Second))
return d.Round(time.Second) + time.Second
}
func (l *Limiter) bucket(client string) *bucket {
return l.buckets.GetOrCreate(client, func() *bucket {
return &bucket{tokens: l.burst, last: l.now()}
})
}
func (l *Limiter) refillLocked(b *bucket) {
now := l.now()
if elapsed := now.Sub(b.last); elapsed > 0 {
b.tokens += elapsed.Seconds() * l.refill
if b.tokens > l.burst {
b.tokens = l.burst
}
b.last = now
}
}
+176
View File
@@ -0,0 +1,176 @@
package auth
import (
"fmt"
"sync"
"testing"
"time"
)
// fakeClock drives the limiter's refill without sleeping.
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
func testLimiter(t *testing.T, burst int, period time.Duration, max int) (*Limiter, *fakeClock) {
t.Helper()
l := NewLimiter(burst, period, max)
clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
l.now = clk.now
return l, clk
}
// Successful requests must cost nothing: a CI runner pushing hundreds of valid
// deploys a second is not the thing this limiter is defending against.
func TestAllowDoesNotConsume(t *testing.T) {
l, _ := testLimiter(t, 3, time.Minute, 100)
for i := 0; i < 1000; i++ {
if !l.Allow("10.0.0.1") {
t.Fatalf("Allow denied a caller that never failed (i=%d)", i)
}
}
}
func TestFailExhaustsBudget(t *testing.T) {
l, _ := testLimiter(t, 3, time.Minute, 100)
const ip = "10.0.0.1"
for i := 0; i < 3; i++ {
if !l.Allow(ip) {
t.Fatalf("denied before the burst was spent (i=%d)", i)
}
l.Fail(ip)
}
if l.Allow(ip) {
t.Error("burst exhausted but the caller is still allowed")
}
// Failing while already throttled must not push the balance negative, or
// the client would take proportionally longer to recover the more it tried.
for i := 0; i < 100; i++ {
l.Fail(ip)
}
if d := l.RetryAfter(ip); d > 2*time.Minute {
t.Errorf("RetryAfter = %v; over-failing drove the bucket negative", d)
}
}
func TestBudgetRefills(t *testing.T) {
l, clk := testLimiter(t, 4, time.Minute, 100)
const ip = "10.0.0.1"
for i := 0; i < 4; i++ {
l.Fail(ip)
}
if l.Allow(ip) {
t.Fatal("expected to be throttled")
}
// One quarter of the period restores one of four tokens.
clk.advance(15 * time.Second)
if !l.Allow(ip) {
t.Error("no token after a quarter period")
}
clk.advance(time.Hour)
for i := 0; i < 4; i++ {
if !l.Allow(ip) {
t.Fatalf("bucket did not refill to full (i=%d)", i)
}
l.Fail(ip)
}
if l.Allow(ip) {
t.Error("bucket refilled past its burst")
}
}
func TestClientsAreIndependent(t *testing.T) {
l, _ := testLimiter(t, 2, time.Minute, 100)
for i := 0; i < 2; i++ {
l.Fail("10.0.0.1")
}
if l.Allow("10.0.0.1") {
t.Error("attacker not throttled")
}
if !l.Allow("10.0.0.2") {
t.Error("one bad client throttled an unrelated one")
}
}
func TestRetryAfter(t *testing.T) {
l, clk := testLimiter(t, 2, time.Minute, 100)
const ip = "10.0.0.1"
if d := l.RetryAfter(ip); d != 0 {
t.Errorf("RetryAfter with budget left = %v, want 0", d)
}
l.Fail(ip)
l.Fail(ip)
d := l.RetryAfter(ip)
if d <= 0 {
t.Fatal("a throttled client must be told to wait a positive time")
}
if d > 2*time.Minute {
t.Errorf("RetryAfter = %v, unreasonably long for a 1m period", d)
}
// Waiting the advertised time must actually be enough.
clk.advance(d)
if !l.Allow(ip) {
t.Errorf("still throttled after waiting the advertised %v", d)
}
}
// The bucket map is keyed by attacker-chosen input, so its bound is load
// bearing: without it the rate limiter becomes the memory exhaustion vector.
func TestBucketMapIsBounded(t *testing.T) {
l, _ := testLimiter(t, 2, time.Minute, 64)
for i := 0; i < 10000; i++ {
l.Fail(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
}
if got := l.buckets.Len(); got > 64 {
t.Errorf("tracking %d clients, cap is 64", got)
}
}
// A nil limiter is the "rate limiting disabled" configuration and must not
// panic in the request path.
func TestNilLimiterAllowsEverything(t *testing.T) {
var l *Limiter
if !l.Allow("10.0.0.1") {
t.Error("nil limiter denied a request")
}
l.Fail("10.0.0.1")
if d := l.RetryAfter("10.0.0.1"); d != 0 {
t.Errorf("RetryAfter = %v, want 0", d)
}
}
func TestLimiterConcurrentUse(t *testing.T) {
l := NewLimiter(50, time.Minute, 256)
var wg sync.WaitGroup
for g := 0; g < 16; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 200; i++ {
ip := fmt.Sprintf("10.0.0.%d", i%8)
l.Allow(ip)
if i%3 == 0 {
l.Fail(ip)
}
l.RetryAfter(ip)
}
}(g)
}
wg.Wait()
}
+134
View File
@@ -0,0 +1,134 @@
// Package auth mints and verifies API tokens.
//
// A token looks like pgs_<keyid>_<secret>. The key id is the public half: it is
// stored in the clear, indexed, printed by `pages key list` and safe to log.
// The secret is 256 bits of crypto/rand, shown to the operator exactly once at
// creation and never stored — only its SHA-256.
//
// Splitting the two is what keeps verification a single indexed lookup instead
// of a table scan comparing every hash, and it gives the CLI something
// displayable that reveals nothing.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"errors"
"strings"
)
const (
// Prefix marks a pages token. Its main job is to be greppable: secret
// scanners and humans can both spot a leaked credential by shape alone.
Prefix = "pgs"
keyIDBytes = 10 // 80 bits -> exactly 16 base32 characters, no padding
secretBytes = 32 // 256 bits -> exactly 43 base64url characters, no padding
// KeyIDLen and secretLen are the encoded lengths. Parse checks them exactly
// so a truncated or padded token is rejected before any lookup happens.
KeyIDLen = 16
secretLen = 43
)
// keyIDEncoding is lowercase base32 so a key id can be typed, double-clicked
// and pasted without case confusion. It is not standard base32; do not swap it
// for base32.StdEncoding without a migration, because existing ids would stop
// decoding.
var keyIDEncoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
// ErrMalformedToken is returned for anything that is not shaped like a token.
//
// It deliberately carries no detail about which check failed and never embeds
// the offending token: these errors reach logs, and a log line quoting a
// near-miss credential is a credential leak.
var ErrMalformedToken = errors.New("auth: malformed token")
// Mint generates a new token. The caller stores keyID and secretHash and hands
// token to the operator; there is no way to recover token afterwards.
func Mint() (token, keyID string, secretHash [32]byte, err error) {
idRaw := make([]byte, keyIDBytes)
if _, err := rand.Read(idRaw); err != nil {
return "", "", [32]byte{}, err
}
secretRaw := make([]byte, secretBytes)
if _, err := rand.Read(secretRaw); err != nil {
return "", "", [32]byte{}, err
}
keyID = keyIDEncoding.EncodeToString(idRaw)
secret := base64.RawURLEncoding.EncodeToString(secretRaw)
return Prefix + "_" + keyID + "_" + secret, keyID, sha256.Sum256([]byte(secret)), nil
}
// Parse splits a token into its two halves, validating shape and alphabet.
//
// This runs before any database work, so it is also the cheap filter that keeps
// junk from reaching the store: an unauthenticated flood of garbage tokens
// costs a few string comparisons each, not a query.
func Parse(token string) (keyID, secret string, err error) {
rest, ok := strings.CutPrefix(token, Prefix+"_")
if !ok {
return "", "", ErrMalformedToken
}
keyID, secret, ok = strings.Cut(rest, "_")
if !ok {
return "", "", ErrMalformedToken
}
if len(keyID) != KeyIDLen || len(secret) != secretLen {
return "", "", ErrMalformedToken
}
if !validKeyID(keyID) || !validSecret(secret) {
return "", "", ErrMalformedToken
}
return keyID, secret, nil
}
// ValidKeyID reports whether s could be a key id. Handlers that take a key id
// from the URL use it to reject junk before querying.
func ValidKeyID(s string) bool { return len(s) == KeyIDLen && validKeyID(s) }
func validKeyID(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '2' && c <= '7') {
continue
}
return false
}
return true
}
func validSecret(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
continue
}
return false
}
return true
}
// HashSecret returns the value stored in api_keys.secret_hash.
//
// A plain SHA-256, not bcrypt or argon2, and that is deliberate. Password
// hashing exists to make low-entropy human-chosen secrets expensive to guess
// offline; this secret is 256 uniformly random bits, so there is no dictionary
// and no brute force to slow down. Running a KDF per request would instead add
// 50-200ms to every API call and hand an unauthenticated client a CPU
// exhaustion attack: each wrong token would force a full key derivation.
func HashSecret(secret string) [32]byte {
return sha256.Sum256([]byte(secret))
}
// SecretMatches compares a presented secret against a stored hash in constant
// time, so a caller cannot learn the hash byte by byte from response timing.
func SecretMatches(secret string, storedHash []byte) bool {
got := HashSecret(secret)
return subtle.ConstantTimeCompare(got[:], storedHash) == 1
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"strings"
"testing"
)
func TestMintParseRoundTrip(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
token, keyID, hash, err := Mint()
if err != nil {
t.Fatalf("Mint: %v", err)
}
if seen[keyID] {
t.Fatalf("Mint reused key id %q", keyID)
}
seen[keyID] = true
if !strings.HasPrefix(token, Prefix+"_") {
t.Errorf("token %q lacks the %q prefix", token, Prefix)
}
gotID, secret, err := Parse(token)
if err != nil {
t.Fatalf("Parse(%q): %v", token, err)
}
if gotID != keyID {
t.Errorf("Parse key id = %q, want %q", gotID, keyID)
}
if len(gotID) != KeyIDLen {
t.Errorf("key id length = %d, want %d", len(gotID), KeyIDLen)
}
if !SecretMatches(secret, hash[:]) {
t.Error("minted secret does not match its own hash")
}
if strings.Contains(token, keyID+"_"+keyID) {
t.Error("secret must not repeat the key id")
}
if !ValidKeyID(keyID) {
t.Errorf("ValidKeyID rejected a minted id %q", keyID)
}
}
}
func TestParseRejectsMalformed(t *testing.T) {
good, keyID, _, err := Mint()
if err != nil {
t.Fatal(err)
}
_, secret, err := Parse(good)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
token string
}{
{"empty", ""},
{"no prefix", keyID + "_" + secret},
{"wrong prefix", "xyz_" + keyID + "_" + secret},
{"prefix only", "pgs_"},
{"no separator", "pgs_" + keyID + secret},
{"short key id", "pgs_" + keyID[:15] + "_" + secret},
{"long key id", "pgs_" + keyID + "a_" + secret},
{"short secret", "pgs_" + keyID + "_" + secret[:42]},
{"long secret", "pgs_" + keyID + "_" + secret + "a"},
{"uppercase key id", "pgs_" + strings.ToUpper(keyID) + "_" + secret},
{"key id with 0 (not in base32)", "pgs_0" + keyID[1:] + "_" + secret},
{"key id with 1 (not in base32)", "pgs_1" + keyID[1:] + "_" + secret},
{"secret with padding", "pgs_" + keyID + "_" + secret[:42] + "="},
{"secret with slash", "pgs_" + keyID + "_" + secret[:42] + "/"},
{"secret with plus", "pgs_" + keyID + "_" + secret[:42] + "+"},
{"embedded NUL", "pgs_" + keyID + "_" + secret[:42] + "\x00"},
{"leading space", " " + good},
{"newline", good + "\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := Parse(tc.token); err == nil {
t.Errorf("Parse(%q) accepted a malformed token", tc.token)
}
})
}
// The parse error must never quote the input: these errors reach logs.
if _, _, err := Parse(good[:len(good)-1] + "x"); err != nil {
if strings.Contains(err.Error(), secret[:20]) {
t.Error("parse error leaks part of the presented secret")
}
}
}
func TestSecretMatches(t *testing.T) {
hash := HashSecret("correct horse battery staple")
if !SecretMatches("correct horse battery staple", hash[:]) {
t.Error("matching secret rejected")
}
if SecretMatches("correct horse battery stapl", hash[:]) {
t.Error("truncated secret accepted")
}
if SecretMatches("", hash[:]) {
t.Error("empty secret accepted")
}
if SecretMatches("correct horse battery staple", nil) {
t.Error("nil stored hash accepted")
}
if SecretMatches("correct horse battery staple", hash[:16]) {
t.Error("truncated stored hash accepted")
}
}
func TestValidKeyID(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"abcdefghijklmnop", true},
{"234567234567abcd", true},
{"", false},
{"abcdefghijklmno", false}, // 15
{"abcdefghijklmnopq", false}, // 17
{"ABCDEFGHIJKLMNOP", false},
{"abcdefghijklmno0", false},
{"abcdefghijklmno1", false},
{"abcdefghijklmno8", false},
{"abcdefghijklmno-", false},
{"abcdefghijklmn/p", false},
}
for _, tc := range cases {
if got := ValidKeyID(tc.in); got != tc.want {
t.Errorf("ValidKeyID(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+247
View File
@@ -0,0 +1,247 @@
package auth
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/iceBear67/simplepages/internal/store"
)
// Identity is what a verified token proves. It is immutable once returned and
// is shared by every request using that key, so callers must not modify it.
type Identity struct {
KeyID string
Scope store.Scope
ProjectID *int64 // nil for admin keys
Name string
ExpiresAt *time.Time
}
// IsAdmin reports whether the identity may act on every project.
func (i *Identity) IsAdmin() bool { return i != nil && i.Scope == store.ScopeAdmin }
// Owns reports whether the identity may act on the project with this row id.
// Admins own everything.
//
// Callers must pass a resolved row id, never a name from the URL: comparing
// names would make the boundary depend on string handling in every handler.
func (i *Identity) Owns(projectID int64) bool {
if i == nil {
return false
}
if i.Scope == store.ScopeAdmin {
return true
}
return i.ProjectID != nil && *i.ProjectID == projectID
}
// Failure reasons. All of them are reported to the client as one indistinct
// 401: telling an unauthenticated caller whether a key exists, is revoked or
// merely expired is free reconnaissance.
var (
ErrUnknownKey = errors.New("auth: unknown key id")
ErrBadSecret = errors.New("auth: secret mismatch")
ErrRevoked = errors.New("auth: key revoked")
ErrExpired = errors.New("auth: key expired")
)
// DefaultCacheTTL bounds how long a revocation can take to become visible if
// the process that revoked it is not this one. Within one process, Invalidate
// makes revocation immediate.
const DefaultCacheTTL = 60 * time.Second
// Verifier turns a bearer token into an Identity.
//
// Verified keys are cached, because otherwise every deploy request would pay a
// database round trip before doing any work. The cache stores only positive
// results: caching unknown key ids would let anyone grow the map without bound
// by presenting random tokens. An unknown id costs one indexed lookup on a
// WITHOUT ROWID table, and the rate limiter covers the flood case.
//
// sync.Map fits this exactly — the key set is small and stable, entries are
// written once and read many times, and different goroutines mostly touch
// different keys.
type Verifier struct {
db *store.DB
log *slog.Logger
ttl time.Duration
cache sync.Map // keyID -> *cacheEntry
gen atomic.Uint64
// Pending last-use timestamps, flushed in batches. Writing last_used_at per
// request would funnel every authenticated read through the single write
// connection, which is the contention the two-pool design exists to avoid.
mu sync.Mutex
touch map[string]time.Time
now func() time.Time // swapped in tests
}
type cacheEntry struct {
ident *Identity
hash []byte
gen uint64
exp time.Time
}
// NewVerifier returns a verifier reading from db. A ttl of zero means
// DefaultCacheTTL.
func NewVerifier(db *store.DB, log *slog.Logger, ttl time.Duration) *Verifier {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Verifier{
db: db,
log: log,
ttl: ttl,
touch: make(map[string]time.Time),
now: time.Now,
}
}
// Verify authenticates a bearer token.
//
// On success it also records the key as used; the timestamp is written to the
// database later, in a batch, so it is approximate by design.
func (v *Verifier) Verify(ctx context.Context, token string) (*Identity, error) {
keyID, secret, err := Parse(token)
if err != nil {
return nil, err
}
now := v.now()
gen := v.gen.Load()
entry, ok := v.lookupCache(keyID, gen, now)
if !ok {
key, err := v.db.KeyByID(ctx, keyID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnknownKey
}
return nil, err
}
entry = &cacheEntry{
ident: identityOf(key),
hash: key.SecretHash,
gen: gen,
exp: now.Add(v.ttl),
}
// Revoked keys are never cached: the entry would only ever produce a
// rejection, and keeping it lets a caller pin memory with a dead key.
if key.RevokedAt != nil {
return nil, ErrRevoked
}
v.cache.Store(keyID, entry)
}
// The comparison happens on every request, cache hit or not. The cache
// saves the database round trip; it must never save the check itself.
if !SecretMatches(secret, entry.hash) {
return nil, ErrBadSecret
}
if entry.ident.ExpiresAt != nil && !now.Before(*entry.ident.ExpiresAt) {
return nil, ErrExpired
}
v.recordUse(keyID, now)
return entry.ident, nil
}
func (v *Verifier) lookupCache(keyID string, gen uint64, now time.Time) (*cacheEntry, bool) {
raw, ok := v.cache.Load(keyID)
if !ok {
return nil, false
}
e := raw.(*cacheEntry)
if e.gen != gen || !now.Before(e.exp) {
v.cache.Delete(keyID)
return nil, false
}
return e, true
}
// Invalidate discards every cached identity.
//
// Called after any key or project change. Bumping a generation counter rather
// than deleting individual entries is deliberate: a caller that forgets which
// ids a change touched cannot leave a stale entry behind, and the cost is one
// atomic load per verification.
func (v *Verifier) Invalidate() { v.gen.Add(1) }
func (v *Verifier) recordUse(keyID string, at time.Time) {
v.mu.Lock()
defer v.mu.Unlock()
if prev, ok := v.touch[keyID]; !ok || at.After(prev) {
v.touch[keyID] = at
}
}
// FlushTouches writes the accumulated last-use timestamps.
//
// The pending set is taken before the write and not restored on failure: a lost
// last_used_at is a cosmetic loss, and retrying would let a persistently
// failing write grow the map without bound.
func (v *Verifier) FlushTouches(ctx context.Context) error {
v.mu.Lock()
pending := v.touch
v.touch = make(map[string]time.Time)
v.mu.Unlock()
if len(pending) == 0 {
return nil
}
return v.db.TouchKeys(ctx, pending)
}
// RunFlusher writes pending last-use timestamps every interval until ctx is
// done, then flushes once more so a clean shutdown does not drop them.
func (v *Verifier) RunFlusher(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Minute
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
// ctx is already cancelled, so the final flush needs its own
// deadline or TouchKeys would return immediately.
final, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := v.FlushTouches(final); err != nil && v.log != nil {
v.log.Warn("final last_used_at flush failed", "error", err)
}
return
case <-t.C:
if err := v.FlushTouches(ctx); err != nil && v.log != nil {
v.log.Warn("last_used_at flush failed", "error", err)
}
}
}
}
func identityOf(k *store.APIKey) *Identity {
// Everything reachable from a cached Identity is copied: the value is shared
// by every concurrent request using that key, so it must not alias a struct
// the store still owns.
id := &Identity{
KeyID: k.ID,
Scope: k.Scope,
Name: k.Name,
}
if k.ProjectID != nil {
pid := *k.ProjectID
id.ProjectID = &pid
}
if k.ExpiresAt != nil {
exp := *k.ExpiresAt
id.ExpiresAt = &exp
}
return id
}
+407
View File
@@ -0,0 +1,407 @@
package auth
import (
"context"
"errors"
"io"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
"github.com/iceBear67/simplepages/internal/store"
)
func testStore(t *testing.T) *store.DB {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
db, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "pages.db"), log)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// mintInto creates a real key in the database and returns its token.
func mintInto(t *testing.T, db *store.DB, scope store.Scope, projectID *int64) (token, keyID string) {
t.Helper()
token, keyID, hash, err := Mint()
if err != nil {
t.Fatal(err)
}
k := &store.APIKey{
ID: keyID,
SecretHash: hash[:],
Scope: scope,
ProjectID: projectID,
Name: "test",
}
if err := db.CreateKey(context.Background(), k); err != nil {
t.Fatalf("CreateKey: %v", err)
}
return token, keyID
}
func newVerifier(t *testing.T, db *store.DB) *Verifier {
t.Helper()
return NewVerifier(db, slog.New(slog.NewTextHandler(io.Discard, nil)), DefaultCacheTTL)
}
func TestVerifyAdminKey(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
id, err := v.Verify(ctx, token)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if id.KeyID != keyID {
t.Errorf("KeyID = %q, want %q", id.KeyID, keyID)
}
if !id.IsAdmin() {
t.Error("admin key did not produce an admin identity")
}
if !id.Owns(1) || !id.Owns(999) {
t.Error("an admin must own every project")
}
if id.ProjectID != nil {
t.Errorf("admin identity carries project %v", *id.ProjectID)
}
}
func TestVerifyProjectKey(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
p := store.DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
token, _ := mintInto(t, db, store.ScopeProject, &p.ID)
id, err := v.Verify(ctx, token)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if id.IsAdmin() {
t.Error("project key produced an admin identity")
}
if !id.Owns(p.ID) {
t.Error("project key does not own its own project")
}
if id.Owns(p.ID + 1) {
t.Error("project key owns someone else's project")
}
}
func TestVerifyRejects(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
_, secret, err := Parse(token)
if err != nil {
t.Fatal(err)
}
// A well-formed token for a key that was never created.
otherToken, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
// The right key id with someone else's secret.
strangerToken, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
_, wrong, err := Parse(strangerToken)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
token string
want error
}{
{"garbage", "not-a-token", ErrMalformedToken},
{"unknown key", otherToken, ErrUnknownKey},
{"wrong secret", Prefix + "_" + keyID + "_" + wrong, ErrBadSecret},
{"empty", "", ErrMalformedToken},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := v.Verify(ctx, tc.token); !errors.Is(err, tc.want) {
t.Errorf("Verify: got %v, want %v", err, tc.want)
}
})
}
// Sanity: the real token still works after all those failures.
if _, err := v.Verify(ctx, Prefix+"_"+keyID+"_"+secret); err != nil {
t.Errorf("valid token rejected after failed attempts: %v", err)
}
}
func TestVerifyRejectsExpiredKey(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID, hash, err := Mint()
if err != nil {
t.Fatal(err)
}
past := time.Now().Add(-time.Hour)
if err := db.CreateKey(ctx, &store.APIKey{
ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, ExpiresAt: &past,
}); err != nil {
t.Fatal(err)
}
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrExpired) {
t.Errorf("got %v, want ErrExpired", err)
}
}
// Expiry must be re-evaluated on every call, not frozen into the cache entry,
// or a key cached one second before it expires would stay valid for the whole
// cache TTL.
func TestCachedKeyStillExpires(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
now := time.Now()
fake := now
var mu sync.Mutex
v.now = func() time.Time {
mu.Lock()
defer mu.Unlock()
return fake
}
token, keyID, hash, err := Mint()
if err != nil {
t.Fatal(err)
}
exp := now.Add(30 * time.Second)
if err := db.CreateKey(ctx, &store.APIKey{
ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, ExpiresAt: &exp,
}); err != nil {
t.Fatal(err)
}
if _, err := v.Verify(ctx, token); err != nil {
t.Fatalf("key should be valid before expiry: %v", err)
}
mu.Lock()
fake = now.Add(31 * time.Second) // still inside the 60s cache TTL
mu.Unlock()
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrExpired) {
t.Errorf("expired key still accepted from cache: %v", err)
}
}
func TestVerifyRejectsRevokedKey(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
if err := db.RevokeKey(ctx, keyID); err != nil {
t.Fatal(err)
}
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
t.Errorf("got %v, want ErrRevoked", err)
}
}
// Revocation has to take effect immediately in the process that performed it;
// this both proves that and demonstrates the cache is really being consulted.
func TestInvalidateMakesRevocationImmediate(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
if _, err := v.Verify(ctx, token); err != nil {
t.Fatal(err)
}
// Revoke behind the verifier's back. The cached entry is still live, so the
// key keeps working — which is exactly what makes the next assertion mean
// something.
if err := db.RevokeKey(ctx, keyID); err != nil {
t.Fatal(err)
}
if _, err := v.Verify(ctx, token); err != nil {
t.Fatalf("cache was not consulted (or the test is not measuring it): %v", err)
}
v.Invalidate()
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
t.Errorf("after Invalidate: got %v, want ErrRevoked", err)
}
}
// An unknown key id must not create a cache entry: the id space is
// attacker-chosen, so caching misses would be an unbounded memory sink.
func TestUnknownKeysAreNotCached(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
for i := 0; i < 200; i++ {
token, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrUnknownKey) {
t.Fatalf("got %v, want ErrUnknownKey", err)
}
}
entries := 0
v.cache.Range(func(any, any) bool { entries++; return true })
if entries != 0 {
t.Errorf("%d unknown key ids were cached", entries)
}
}
// Same idea for revoked keys: a dead credential must not pin memory.
func TestRevokedKeysAreNotCached(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
if err := db.RevokeKey(ctx, keyID); err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
t.Fatal(err)
}
}
entries := 0
v.cache.Range(func(any, any) bool { entries++; return true })
if entries != 0 {
t.Errorf("%d revoked keys were cached", entries)
}
}
func TestFlushTouches(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
if err := v.FlushTouches(ctx); err != nil {
t.Errorf("flushing an empty batch: %v", err)
}
before, err := db.KeyByID(ctx, keyID)
if err != nil {
t.Fatal(err)
}
if before.LastUsedAt != nil {
t.Error("last_used_at set before any use")
}
for i := 0; i < 5; i++ {
if _, err := v.Verify(ctx, token); err != nil {
t.Fatal(err)
}
}
// Nothing is written until the batch is flushed; that is the whole point of
// keeping the write connection out of the request path.
mid, err := db.KeyByID(ctx, keyID)
if err != nil {
t.Fatal(err)
}
if mid.LastUsedAt != nil {
t.Error("last_used_at written per request instead of in a batch")
}
if err := v.FlushTouches(ctx); err != nil {
t.Fatalf("FlushTouches: %v", err)
}
after, err := db.KeyByID(ctx, keyID)
if err != nil {
t.Fatal(err)
}
if after.LastUsedAt == nil {
t.Fatal("last_used_at still unset after flush")
}
// A flush drains the pending set, so a second one has nothing to do.
v.mu.Lock()
pending := len(v.touch)
v.mu.Unlock()
if pending != 0 {
t.Errorf("%d pending touches survived the flush", pending)
}
}
func TestRunFlusherFlushesOnShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
db := testStore(t)
v := newVerifier(t, db)
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
if _, err := v.Verify(ctx, token); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
defer close(done)
v.RunFlusher(ctx, time.Hour) // never ticks; only the shutdown path runs
}()
cancel()
<-done
k, err := db.KeyByID(context.Background(), keyID)
if err != nil {
t.Fatal(err)
}
if k.LastUsedAt == nil {
t.Error("shutdown flush dropped the pending timestamps")
}
}
func TestVerifyIsConcurrencySafe(t *testing.T) {
ctx := context.Background()
db := testStore(t)
v := newVerifier(t, db)
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
bad, _, _, err := Mint()
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for g := 0; g < 16; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 100; i++ {
if g%4 == 0 {
if _, err := v.Verify(ctx, bad); err == nil {
t.Error("bad token accepted")
}
continue
}
if _, err := v.Verify(ctx, token); err != nil {
t.Errorf("good token rejected: %v", err)
return
}
if i%25 == 0 {
v.Invalidate()
}
}
}(g)
}
wg.Wait()
}