init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user