// 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 } } }