Files
kjol/go/auth/auth.go
2026-07-14 16:49:56 -04:00

275 lines
10 KiB
Go

// Package auth is a storage-agnostic authentication and authorization engine.
//
// The engine owns the mechanics every app otherwise reimplements: password login
// with timing-attack mitigation and lockout, opaque session keys carried by
// either cookie or bearer token, session expiry and revocation, per-request
// permission resolution, and the authn/authz middleware.
//
// It owns no schema. Everything app-specific is injected through three
// interfaces:
//
// Store persist and look up sessions
// Directory find a user by login identifier, record login outcomes
// PermissionResolver turn a session into an effective permission set
//
// so one app can key login on a username and another on an email-or-phone
// without the engine knowing that either concept exists. Apps keep their own
// permission constants; the engine only needs to be told which one means
// "superuser" (Config.SuperPermission).
package auth
import (
"context"
"errors"
"net/http"
"time"
"github.com/google/uuid"
)
// Errors an app's Store or Directory is expected to return. The engine maps all
// of them onto ErrInvalidCredentials at the login boundary so a caller can never
// distinguish "no such user" from "wrong password" by inspecting the error.
var (
ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found")
ErrAPIKeyNotFound = errors.New("auth: api key not found")
ErrInvalidCredentials = errors.New("auth: invalid credentials")
)
// Session is a live login. It is the engine's view of whatever row the app
// stores; the app converts to and from its own model in its Store.
//
// ID is assigned by the app's store (typically a database default), so it is
// zero on the Session handed to Store.Insert.
type Session struct {
ID uuid.UUID
Key string // opaque secret; the cookie/bearer value
UserID uuid.UUID
OrgID *uuid.UUID // set when the login is scoped to one organization
Expiration time.Time
Created time.Time
UserAgent string
IPAddr string
Timezone string
Revoked bool
}
// Expired reports whether the session is past its expiration.
func (s Session) Expired() bool { return s.Expiration.Before(time.Now()) }
// Valid reports whether the session may still authenticate a request.
func (s Session) Valid() bool { return !s.Revoked && !s.Expired() }
// User is the engine's view of a login-capable account: the minimum it needs to
// verify a password and seed a session. The app's Directory projects its own
// user model onto this.
type User struct {
ID uuid.UUID
PasswordHash string
FailedLoginAttempts int
Timezone string
OrgID *uuid.UUID // pre-selected org, when the app can infer one at login
}
// APIKey is a non-interactive, organization-scoped credential. Optional: an app
// only sees these if it sets Config.APIKeys.
type APIKey struct {
ID uuid.UUID
OrgID uuid.UUID
Revoked bool
}
// Principal is the authenticated state of one request. It is attached to the
// request context by LoadContext and read back with PrincipalFrom.
//
// An unauthenticated request carries the zero Principal rather than nothing, so
// handlers behind an optional-auth route can read it without a nil check.
type Principal struct {
Authenticated bool
Session Session
Permissions map[string]bool
IsAPIKey bool // authenticated by APIKey rather than a user session
}
// SessionMeta is the per-request provenance recorded on a new session.
type SessionMeta struct {
IPAddr string
UserAgent string
}
// Store persists sessions. Implementations are expected to be safe for
// concurrent use.
type Store interface {
// FetchByKey returns the session with the given opaque key, or
// ErrSessionNotFound. Returning a revoked or expired session is fine; the
// engine checks both.
FetchByKey(ctx context.Context, key string) (Session, error)
// Insert persists a newly minted session. The session's ID is zero; a store
// that generates IDs (or lets the database do it) may ignore the field.
Insert(ctx context.Context, s Session) error
// Revoke marks the session with the given key as revoked.
Revoke(ctx context.Context, key string) error
// EnforceSessionLimit revokes the user's oldest active sessions until at
// most limit remain. Called before Insert, and only when
// Config.MaxActiveSessions is positive.
EnforceSessionLimit(ctx context.Context, userID uuid.UUID, limit int) error
}
// Directory resolves login identifiers to users and records login outcomes.
// What an identifier *is* — username, email, phone — is entirely the app's
// business.
type Directory interface {
// FindByIdentifier returns the user for a login identifier, or
// ErrUserNotFound. The engine treats every error as a failed login.
FindByIdentifier(ctx context.Context, identifier string) (User, error)
// RecordLoginResult records a login attempt: on success, reset the failure
// counter and stamp last-login; on failure, increment the counter. The
// engine ignores the returned error beyond logging, so a failure here can
// never turn a bad password into a good one.
RecordLoginResult(ctx context.Context, u User, success bool) error
}
// PermissionResolver computes a session's effective permissions. The engine
// calls it on every authenticated request, so implementations should be cheap or
// cached.
//
// It takes the whole Session, not just a user ID, so a resolver can vary by how
// the session was created — e.g. returning a snapshot captured at SSO login
// instead of aggregating live from the database.
type PermissionResolver interface {
Resolve(ctx context.Context, session Session) map[string]bool
}
// APIKeyDirectory resolves organization-scoped API keys. Optional.
type APIKeyDirectory interface {
// FindAPIKeyByHash looks up a key by its digest (see HashAPIKey), or returns
// ErrAPIKeyNotFound. Raw keys are never persisted.
FindAPIKeyByHash(ctx context.Context, hash string) (APIKey, error)
// TouchAPIKey records that the key was used. Called on a background
// goroutine; errors are ignored.
TouchAPIKey(ctx context.Context, id uuid.UUID)
}
// PasswordPolicy is the complexity floor enforced by CheckPassword. The zero
// policy accepts any non-blank password.
type PasswordPolicy struct {
MinLength int
RequiredUppercase int
RequiredLowercase int
RequiredNumbers int
RequiredSymbols int
}
// Config parameterizes the engine. It carries no secrets and no schema — only
// the knobs that differ between applications.
type Config struct {
// Session cookie and the paths the middleware redirects between.
CookieName string
LoginPath string
LogoutPath string
DefaultPath string
// Redirect sends unauthenticated browser requests to LoginPath. When false,
// they get a bare 401. Bearer-token and /api/ requests never redirect
// regardless.
Redirect bool
// SessionTTL is how long a new session stays valid.
SessionTTL time.Duration
// KeyBytes is the entropy of a session key, in bytes.
KeyBytes int
// MaxActiveSessions caps concurrent sessions per user; the oldest are
// revoked past the cap. Zero or less disables the cap.
MaxActiveSessions int
// MaxLoginAttempts locks an account out once its consecutive failure count
// exceeds this. Zero or less disables lockout.
MaxLoginAttempts int
// SuperPermission is the app's wildcard permission (conventionally "*"),
// which satisfies any Require check. Empty means no wildcard exists.
SuperPermission string
// Implications grants permissions transitively: holding the key grants every
// permission in the value. Applied after the resolver returns, so a
// "manage X" permission can imply "view X" without every role having to list
// both. An explicit deny (present in the map, set false) is never overridden.
Implications map[string][]string
// Password is the complexity policy enforced by CheckPassword.
Password PasswordPolicy
// APIKeys enables the LoadAPIKey middleware. Optional; nil means the app has
// no API-key credentials.
APIKeys APIKeyDirectory
}
// Authenticator is the engine. Build one with New and keep it for the process
// lifetime; it is safe for concurrent use.
type Authenticator struct {
cfg Config
store Store
dir Directory
resolver PermissionResolver
}
// New builds an Authenticator from a config and the app's three adapters. All
// three are required; APIKeys is set on Config when the app has API keys.
func New(cfg Config, store Store, dir Directory, resolver PermissionResolver) *Authenticator {
return &Authenticator{cfg: cfg, store: store, dir: dir, resolver: resolver}
}
// Config returns the engine's configuration.
func (a *Authenticator) Config() Config { return a.cfg }
// principalCtxKey types the request-context slot holding the Principal. It is
// unexported, so nothing outside this package can plant or forge one.
type principalCtxKey struct{}
// PrincipalFrom returns the Principal that LoadContext (or LoadAPIKey) attached
// to the request. Requests that did not pass through the middleware, and
// unauthenticated ones, yield the zero Principal.
func PrincipalFrom(r *http.Request) Principal {
p, _ := r.Context().Value(principalCtxKey{}).(Principal)
return p
}
// withPrincipal returns r carrying p.
func withPrincipal(r *http.Request, p Principal) *http.Request {
return r.WithContext(context.WithValue(r.Context(), principalCtxKey{}, p))
}
// resolvePermissions computes a session's permissions and applies Implications.
func (a *Authenticator) resolvePermissions(ctx context.Context, s Session) map[string]bool {
perms := a.resolver.Resolve(ctx, s)
if perms == nil {
perms = map[string]bool{}
}
applyImplications(perms, a.cfg.Implications)
return perms
}
// applyImplications expands perms in place: holding a source permission grants
// its targets. An explicit deny (key present and false) stays denied.
func applyImplications(perms map[string]bool, implications map[string][]string) {
for source, targets := range implications {
if !perms[source] {
continue
}
for _, target := range targets {
if granted, explicit := perms[target]; explicit && !granted {
continue // explicit deny wins
}
perms[target] = true
}
}
}