275 lines
10 KiB
Go
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
|
|
}
|
|
}
|
|
}
|