factor out kjol auth
This commit is contained in:
274
go/auth/auth.go
Normal file
274
go/auth/auth.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user