diff --git a/go/auth/auth.go b/go/auth/auth.go new file mode 100644 index 00000000..db0fd211 --- /dev/null +++ b/go/auth/auth.go @@ -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 + } + } +} diff --git a/go/auth/auth_test.go b/go/auth/auth_test.go new file mode 100644 index 00000000..48d983e9 --- /dev/null +++ b/go/auth/auth_test.go @@ -0,0 +1,1014 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "kjol/security" + + "github.com/google/uuid" +) + +// ---- fakes ----------------------------------------------------------------- + +type fakeStore struct { + mu sync.Mutex + sessions map[string]Session + inserted []Session + limits []int // limit passed to each EnforceSessionLimit call + revoked []string +} + +func newFakeStore() *fakeStore { + return &fakeStore{sessions: map[string]Session{}} +} + +func (s *fakeStore) FetchByKey(_ context.Context, key string) (Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[key] + if !ok { + return Session{}, ErrSessionNotFound + } + return session, nil +} + +func (s *fakeStore) Insert(_ context.Context, session Session) error { + s.mu.Lock() + defer s.mu.Unlock() + s.inserted = append(s.inserted, session) // exactly as the engine handed it over + + stored := session + stored.ID = uuid.New() // stand in for the database default + s.sessions[session.Key] = stored + return nil +} + +func (s *fakeStore) Revoke(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[key] + if !ok { + return ErrSessionNotFound + } + session.Revoked = true + s.sessions[key] = session + s.revoked = append(s.revoked, key) + return nil +} + +func (s *fakeStore) EnforceSessionLimit(_ context.Context, _ uuid.UUID, limit int) error { + s.mu.Lock() + defer s.mu.Unlock() + s.limits = append(s.limits, limit) + return nil +} + +// put installs a session directly, bypassing login. +func (s *fakeStore) put(session Session) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[session.Key] = session +} + +type fakeDirectory struct { + mu sync.Mutex + users map[string]User + outcomes []bool // one per RecordLoginResult call +} + +func (d *fakeDirectory) FindByIdentifier(_ context.Context, identifier string) (User, error) { + d.mu.Lock() + defer d.mu.Unlock() + u, ok := d.users[identifier] + if !ok { + return User{}, ErrUserNotFound + } + return u, nil +} + +func (d *fakeDirectory) RecordLoginResult(_ context.Context, _ User, success bool) error { + d.mu.Lock() + defer d.mu.Unlock() + d.outcomes = append(d.outcomes, success) + return nil +} + +// fakeResolver hands back a fixed permission set, cloned per call so the engine +// can't mutate the fixture out from under a later test. +type fakeResolver struct{ perms map[string]bool } + +func (r fakeResolver) Resolve(_ context.Context, _ Session) map[string]bool { + out := make(map[string]bool, len(r.perms)) + for k, v := range r.perms { + out[k] = v + } + return out +} + +// ---- harness --------------------------------------------------------------- + +const ( + testPassword = "correct-horse" + testUsername = "mta" +) + +type harness struct { + auth *Authenticator + str *fakeStore + dir *fakeDirectory + user User +} + +func newHarness(t *testing.T, mutate ...func(*Config)) *harness { + t.Helper() + + hash, err := security.HashPassword(testPassword) + if err != nil { + t.Fatalf("hash password: %v", err) + } + + orgID := uuid.New() + user := User{ + ID: uuid.New(), + PasswordHash: hash, + Timezone: "UTC", + OrgID: &orgID, + } + + cfg := Config{ + CookieName: "test_identity", + LoginPath: "/auth/login", + LogoutPath: "/auth/logout", + DefaultPath: "/app/dashboard", + Redirect: true, + SessionTTL: 24 * time.Hour, + KeyBytes: 32, + MaxActiveSessions: 5, + MaxLoginAttempts: 25, + SuperPermission: "*", + } + for _, m := range mutate { + m(&cfg) + } + + str := newFakeStore() + dir := &fakeDirectory{users: map[string]User{testUsername: user}} + + return &harness{ + auth: New(cfg, str, dir, fakeResolver{perms: map[string]bool{"read": true}}), + str: str, + dir: dir, + user: user, + } +} + +// okHandler records that the request made it through the middleware. +func okHandler(served *bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + *served = true + w.WriteHeader(http.StatusOK) + } +} + +// ---- login ----------------------------------------------------------------- + +func TestLoginSuccess(t *testing.T) { + h := newHarness(t) + + principal, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{ + IPAddr: "10.0.0.1", UserAgent: "test-agent", + }) + if err != nil { + t.Fatalf("Login: %v", err) + } + + if !principal.Authenticated { + t.Error("principal is not authenticated") + } + if key == "" || principal.Session.Key != key { + t.Errorf("session key not returned: key=%q session.Key=%q", key, principal.Session.Key) + } + if principal.Session.UserID != h.user.ID { + t.Errorf("UserID = %v, want %v", principal.Session.UserID, h.user.ID) + } + if principal.Session.OrgID == nil || *principal.Session.OrgID != *h.user.OrgID { + t.Error("session did not inherit the user's org") + } + if principal.Session.IPAddr != "10.0.0.1" || principal.Session.UserAgent != "test-agent" { + t.Error("session did not record the request metadata") + } + if !principal.Permissions["read"] { + t.Error("permissions were not resolved onto the principal") + } + if got, want := principal.Session.Expiration, time.Now().Add(24*time.Hour); got.Sub(want).Abs() > time.Minute { + t.Errorf("Expiration = %v, want ~%v", got, want) + } + + if len(h.str.inserted) != 1 { + t.Fatalf("inserted %d sessions, want 1", len(h.str.inserted)) + } + // The store (or its database default) owns ID assignment: cdrateline's + // InsertIdentity omits the id column entirely, so an engine-invented ID + // would be silently dropped and disagree with the stored row. + if h.str.inserted[0].ID != uuid.Nil { + t.Errorf("engine assigned session ID %v; that is the store's job", h.str.inserted[0].ID) + } + if len(h.dir.outcomes) != 1 || !h.dir.outcomes[0] { + t.Errorf("login outcomes = %v, want [true]", h.dir.outcomes) + } + if len(h.str.limits) != 1 || h.str.limits[0] != 5 { + t.Errorf("session limits enforced = %v, want [5]", h.str.limits) + } +} + +func TestLoginRejectsBadPasswordAndRecordsFailure(t *testing.T) { + h := newHarness(t) + + _, _, err := h.auth.Login(t.Context(), testUsername, "wrong", SessionMeta{}) + if err != ErrInvalidCredentials { + t.Fatalf("err = %v, want ErrInvalidCredentials", err) + } + if len(h.str.inserted) != 0 { + t.Error("a session was created for a bad password") + } + if len(h.dir.outcomes) != 1 || h.dir.outcomes[0] { + t.Errorf("login outcomes = %v, want [false]", h.dir.outcomes) + } +} + +func TestLoginRejectsUnknownUserIndistinguishably(t *testing.T) { + h := newHarness(t) + + _, _, err := h.auth.Login(t.Context(), "nobody", testPassword, SessionMeta{}) + if err != ErrInvalidCredentials { + t.Fatalf("err = %v, want ErrInvalidCredentials", err) + } + // Nothing to record against: the user does not exist. + if len(h.dir.outcomes) != 0 { + t.Errorf("recorded a login outcome for a nonexistent user: %v", h.dir.outcomes) + } +} + +func TestLoginLocksOutAfterMaxAttempts(t *testing.T) { + h := newHarness(t, func(c *Config) { c.MaxLoginAttempts = 3 }) + + locked := h.user + locked.FailedLoginAttempts = 4 // past the cap + h.dir.users[testUsername] = locked + + // The correct password must not open a locked-out account. + _, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) + if err != ErrInvalidCredentials { + t.Fatalf("err = %v, want ErrInvalidCredentials for a locked-out account", err) + } + if len(h.str.inserted) != 0 { + t.Error("a locked-out account was issued a session") + } +} + +func TestLoginLockoutDisabledWhenMaxAttemptsUnset(t *testing.T) { + h := newHarness(t, func(c *Config) { c.MaxLoginAttempts = 0 }) + + noisy := h.user + noisy.FailedLoginAttempts = 999 + h.dir.users[testUsername] = noisy + + if _, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}); err != nil { + t.Fatalf("Login: %v, want success when lockout is disabled", err) + } +} + +func TestLoginSkipsSessionLimitWhenDisabled(t *testing.T) { + h := newHarness(t, func(c *Config) { c.MaxActiveSessions = 0 }) + + if _, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}); err != nil { + t.Fatalf("Login: %v", err) + } + if len(h.str.limits) != 0 { + t.Errorf("enforced a session limit though the cap is disabled: %v", h.str.limits) + } +} + +func TestLogoutRevokesSession(t *testing.T) { + h := newHarness(t) + + _, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) + if err != nil { + t.Fatalf("Login: %v", err) + } + if err := h.auth.Logout(t.Context(), key); err != nil { + t.Fatalf("Logout: %v", err) + } + + session, _ := h.str.FetchByKey(t.Context(), key) + if !session.Revoked { + t.Error("session was not revoked") + } + // Revoking an unknown or already-revoked session is a no-op, not an error. + if err := h.auth.Logout(t.Context(), "no-such-key"); err != nil { + t.Errorf("Logout of unknown key: %v, want nil", err) + } +} + +// ---- LoadContext ----------------------------------------------------------- + +// login logs in and returns the raw session key. +func (h *harness) login(t *testing.T) string { + t.Helper() + _, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) + if err != nil { + t.Fatalf("Login: %v", err) + } + return key +} + +func TestLoadContextAuthenticatesViaCookie(t *testing.T) { + h := newHarness(t) + key := h.login(t) + + var got Principal + handler := h.auth.LoadContext(func(w http.ResponseWriter, r *http.Request) { + got = PrincipalFrom(r) + }, true) + + r := httptest.NewRequest(http.MethodGet, "/app/dashboard", nil) + r.AddCookie(&http.Cookie{Name: "test_identity", Value: key}) + w := httptest.NewRecorder() + handler(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if !got.Authenticated || got.Session.UserID != h.user.ID { + t.Error("cookie session did not authenticate") + } + if !got.Permissions["read"] { + t.Error("permissions were not attached to the request") + } +} + +func TestLoadContextAuthenticatesViaBearerToken(t *testing.T) { + h := newHarness(t) + key := h.login(t) + + var got Principal + handler := h.auth.LoadContext(func(w http.ResponseWriter, r *http.Request) { + got = PrincipalFrom(r) + }, true) + + r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) + r.Header.Set("Authorization", "Bearer "+key) + w := httptest.NewRecorder() + handler(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if !got.Authenticated { + t.Error("bearer token did not authenticate") + } +} + +func TestLoadContextOptionalAuthServesAnonymously(t *testing.T) { + h := newHarness(t) + + var got Principal + served := false + handler := h.auth.LoadContext(func(w http.ResponseWriter, r *http.Request) { + served, got = true, PrincipalFrom(r) + }, false) + + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodGet, "/", nil)) + + if !served { + t.Fatal("optional-auth route did not serve an anonymous request") + } + if got.Authenticated || got.Permissions != nil { + t.Error("anonymous request did not get the zero Principal") + } +} + +func TestLoadContextRedirectsAnonymousBrowserToLogin(t *testing.T) { + h := newHarness(t) + + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodGet, "/app/secret?x=1", nil)) + + if served { + t.Fatal("an anonymous request reached a protected handler") + } + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + if got, want := w.Header().Get("Location"), "/auth/login?redirect=%2Fapp%2Fsecret%3Fx%3D1"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } +} + +func TestLoadContextAnswersAPIWithJSON401(t *testing.T) { + h := newHarness(t) + + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodGet, "/api/user/profile", nil)) + + if served { + t.Fatal("an anonymous request reached a protected API handler") + } + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if !strings.Contains(w.Body.String(), "valid authentication required") { + t.Errorf("body = %q", w.Body.String()) + } +} + +func TestLoadContextRejectsExpiredSession(t *testing.T) { + h := newHarness(t) + h.str.put(Session{ + Key: "expired-key", + UserID: h.user.ID, + Created: time.Now().Add(-48 * time.Hour), + Expiration: time.Now().Add(-time.Hour), + }) + + t.Run("api gets a JSON 401", func(t *testing.T) { + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) + r.AddCookie(&http.Cookie{Name: "test_identity", Value: "expired-key"}) + w := httptest.NewRecorder() + handler(w, r) + + if served || w.Code != http.StatusUnauthorized { + t.Fatalf("served=%v status=%d, want false/401", served, w.Code) + } + if !strings.Contains(w.Body.String(), "session expired") { + t.Errorf("body = %q, want it to name the expiry", w.Body.String()) + } + }) + + t.Run("browser is redirected and has its dead cookie cleared", func(t *testing.T) { + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + r := httptest.NewRequest(http.MethodGet, "/app/dashboard", nil) + r.AddCookie(&http.Cookie{Name: "test_identity", Value: "expired-key"}) + w := httptest.NewRecorder() + handler(w, r) + + if served || w.Code != http.StatusFound { + t.Fatalf("served=%v status=%d, want false/302", served, w.Code) + } + cookie := w.Result().Cookies()[0] + if cookie.Name != "test_identity" || cookie.MaxAge != -1 { + t.Errorf("expired session did not clear the cookie: %+v", cookie) + } + }) +} + +func TestLoadContextRejectsRevokedSession(t *testing.T) { + h := newHarness(t) + h.str.put(Session{ + Key: "revoked-key", + UserID: h.user.ID, + Expiration: time.Now().Add(time.Hour), + Revoked: true, + }) + + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) + r.AddCookie(&http.Cookie{Name: "test_identity", Value: "revoked-key"}) + w := httptest.NewRecorder() + handler(w, r) + + if served { + t.Fatal("a revoked session authenticated a request") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestLoadContextSendsAuthenticatedUserAwayFromLoginPage(t *testing.T) { + h := newHarness(t) + key := h.login(t) + + served := false + handler := h.auth.LoadContext(okHandler(&served), false) + + r := httptest.NewRequest(http.MethodGet, "/auth/login", nil) + r.AddCookie(&http.Cookie{Name: "test_identity", Value: key}) + w := httptest.NewRecorder() + handler(w, r) + + if w.Code != http.StatusFound || w.Header().Get("Location") != "/app/dashboard" { + t.Errorf("status=%d Location=%q, want 302 to /app/dashboard", w.Code, w.Header().Get("Location")) + } +} + +func TestLoadContextServesLoginPageToAnonymousUser(t *testing.T) { + h := newHarness(t) + + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + + if !served { + t.Fatal("the login page must be reachable while signed out, or login is impossible") + } +} + +// A bearer-token request must not disable redirects for the browser requests +// that follow it. The middleware previously kept `redirect` in the enclosing +// closure and cleared it on seeing a bearer token, so one API call permanently +// converted this route's redirect-to-login into a bare 401 for every user. +func TestLoadContextBearerRequestDoesNotLeakIntoLaterRequests(t *testing.T) { + h := newHarness(t) + + served := false + handler := h.auth.LoadContext(okHandler(&served), true) + + // An anonymous API call carrying a bearer token: answered with JSON, no redirect. + r := httptest.NewRequest(http.MethodGet, "/api/user/profile", nil) + r.Header.Set("Authorization", "Bearer bogus") + handler(httptest.NewRecorder(), r) + + // A browser hitting a page on the same route must still be redirected. + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodGet, "/app/secret", nil)) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302: a prior bearer request leaked into this one", w.Code) + } +} + +// ---- authorization --------------------------------------------------------- + +func principalWith(perms ...string) Principal { + p := Principal{Authenticated: true, Permissions: map[string]bool{}} + for _, perm := range perms { + p.Permissions[perm] = true + } + return p +} + +func TestAuthorized(t *testing.T) { + a := newHarness(t).auth + + tests := []struct { + name string + held Principal + need []string + allow bool + }{ + {"holds the permission", principalWith("read"), []string{"read"}, true}, + {"missing the permission", principalWith("read"), []string{"write"}, false}, + {"holds all of several", principalWith("read", "write"), []string{"read", "write"}, true}, + {"holds only some of several", principalWith("read"), []string{"read", "write"}, false}, + {"super permission satisfies anything", principalWith("*"), []string{"write"}, true}, + {"no requirements", principalWith(), nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := a.Authorized(tt.held, tt.need...); got != tt.allow { + t.Errorf("Authorized = %v, want %v", got, tt.allow) + } + }) + } +} + +func TestAuthorizedExactIgnoresSuperPermission(t *testing.T) { + a := newHarness(t).auth + + super := principalWith("*") + if !a.Authorized(super, "write") { + t.Error("Authorized: the super permission should satisfy any requirement") + } + if a.AuthorizedExact(super, "write") { + t.Error("AuthorizedExact: the super permission must not bypass an exact check") + } + if !a.AuthorizedExact(principalWith("write"), "write") { + t.Error("AuthorizedExact: a genuinely held permission should pass") + } +} + +func TestAuthorizedAny(t *testing.T) { + a := newHarness(t).auth + + if !a.AuthorizedAny(principalWith("write"), "read", "write") { + t.Error("AuthorizedAny: holding one of the listed permissions should pass") + } + if a.AuthorizedAny(principalWith("delete"), "read", "write") { + t.Error("AuthorizedAny: holding none of them should fail") + } + if !a.AuthorizedAny(principalWith("*"), "read") { + t.Error("AuthorizedAny: the super permission should satisfy it") + } + if a.AuthorizedAny(principalWith("read")) { + t.Error("AuthorizedAny: an empty list grants nothing") + } +} + +// requireCase drives a Require-family middleware over a request whose principal +// holds perms, and reports the status and whether the handler ran. +func requireCase(t *testing.T, mw func(http.HandlerFunc) http.HandlerFunc, path string, perms ...string) (int, bool) { + t.Helper() + + served := false + r := withPrincipal(httptest.NewRequest(http.MethodGet, path, nil), principalWith(perms...)) + w := httptest.NewRecorder() + mw(okHandler(&served))(w, r) + return w.Code, served +} + +func TestRequireGatesHandler(t *testing.T) { + a := newHarness(t).auth + + t.Run("allows a caller holding the permission", func(t *testing.T) { + mw := func(h http.HandlerFunc) http.HandlerFunc { return a.Require(h, "read") } + if code, served := requireCase(t, mw, "/api/x", "read"); !served || code != http.StatusOK { + t.Errorf("status=%d served=%v, want 200/true", code, served) + } + }) + + t.Run("denies a caller missing it, in JSON on an API route", func(t *testing.T) { + mw := func(h http.HandlerFunc) http.HandlerFunc { return a.Require(h, "write") } + code, served := requireCase(t, mw, "/api/x", "read") + if served || code != http.StatusForbidden { + t.Errorf("status=%d served=%v, want 403/false", code, served) + } + }) + + t.Run("denies a browser caller with a bare 403", func(t *testing.T) { + mw := func(h http.HandlerFunc) http.HandlerFunc { return a.Require(h, "write") } + if code, served := requireCase(t, mw, "/app/x", "read"); served || code != http.StatusForbidden { + t.Errorf("status=%d served=%v, want 403/false", code, served) + } + }) + + t.Run("denies an anonymous caller", func(t *testing.T) { + served := false + w := httptest.NewRecorder() + a.Require(okHandler(&served), "read")(w, httptest.NewRequest(http.MethodGet, "/api/x", nil)) + if served || w.Code != http.StatusForbidden { + t.Errorf("status=%d served=%v, want 403/false", w.Code, served) + } + }) +} + +func TestRequireExactAndRequireAnyMiddleware(t *testing.T) { + a := newHarness(t).auth + + exact := func(h http.HandlerFunc) http.HandlerFunc { return a.RequireExact(h, "write") } + if code, served := requireCase(t, exact, "/api/x", "*"); served || code != http.StatusForbidden { + t.Errorf("RequireExact: status=%d served=%v, want 403/false for a super-permission holder", code, served) + } + + any := func(h http.HandlerFunc) http.HandlerFunc { return a.RequireAny(h, "read", "write") } + if code, served := requireCase(t, any, "/api/x", "write"); !served || code != http.StatusOK { + t.Errorf("RequireAny: status=%d served=%v, want 200/true", code, served) + } + if code, served := requireCase(t, any, "/api/x", "delete"); served || code != http.StatusForbidden { + t.Errorf("RequireAny: status=%d served=%v, want 403/false", code, served) + } +} + +// ---- implications ---------------------------------------------------------- + +func TestImplicationsGrantTransitively(t *testing.T) { + h := newHarness(t, func(c *Config) { + c.Implications = map[string][]string{ + "manage_events": {"view_events", "view_seasons"}, + } + }) + h.auth.resolver = fakeResolver{perms: map[string]bool{"manage_events": true}} + + _, key, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) + if err != nil { + t.Fatalf("Login: %v", err) + } + + session, _ := h.str.FetchByKey(t.Context(), key) + perms := h.auth.resolvePermissions(t.Context(), session) + + for _, want := range []string{"view_events", "view_seasons"} { + if !perms[want] { + t.Errorf("%q was not implied by manage_events", want) + } + } +} + +func TestImplicationsRespectExplicitDeny(t *testing.T) { + h := newHarness(t, func(c *Config) { + c.Implications = map[string][]string{"manage_events": {"view_events"}} + }) + // An explicit deny — the permission is present and false, not merely absent. + h.auth.resolver = fakeResolver{perms: map[string]bool{ + "manage_events": true, + "view_events": false, + }} + + perms := h.auth.resolvePermissions(t.Context(), Session{}) + if perms["view_events"] { + t.Error("an implication overrode an explicit deny") + } +} + +func TestImplicationsIgnoreUnheldSource(t *testing.T) { + h := newHarness(t, func(c *Config) { + c.Implications = map[string][]string{"manage_events": {"view_events"}} + }) + h.auth.resolver = fakeResolver{perms: map[string]bool{"read": true}} + + if perms := h.auth.resolvePermissions(t.Context(), Session{}); perms["view_events"] { + t.Error("an implication fired though its source permission is not held") + } +} + +// ---- api keys -------------------------------------------------------------- + +type fakeAPIKeys struct { + mu sync.Mutex + keys map[string]APIKey + touched []uuid.UUID +} + +func (k *fakeAPIKeys) FindAPIKeyByHash(_ context.Context, hash string) (APIKey, error) { + k.mu.Lock() + defer k.mu.Unlock() + key, ok := k.keys[hash] + if !ok { + return APIKey{}, ErrAPIKeyNotFound + } + return key, nil +} + +func (k *fakeAPIKeys) TouchAPIKey(_ context.Context, id uuid.UUID) { + k.mu.Lock() + defer k.mu.Unlock() + k.touched = append(k.touched, id) +} + +func TestLoadAPIKey(t *testing.T) { + const raw = "sk_live_secret" + orgID := uuid.New() + + newAuth := func(key APIKey) (*Authenticator, *fakeAPIKeys) { + keys := &fakeAPIKeys{keys: map[string]APIKey{HashAPIKey(raw): key}} + h := newHarness(t, func(c *Config) { c.APIKeys = keys }) + return h.auth, keys + } + + t.Run("a valid key authenticates and scopes the request to its org", func(t *testing.T) { + a, keys := newAuth(APIKey{ID: uuid.New(), OrgID: orgID}) + + var got Principal + r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) + r.Header.Set("Authorization", "Bearer "+raw) + w := httptest.NewRecorder() + a.LoadAPIKey(func(w http.ResponseWriter, r *http.Request) { got = PrincipalFrom(r) })(w, r) + + if !got.Authenticated || !got.IsAPIKey { + t.Fatalf("principal = %+v, want an authenticated API-key principal", got) + } + if got.Session.OrgID == nil || *got.Session.OrgID != orgID { + t.Error("principal was not scoped to the key's org") + } + if got.Session.UserID != uuid.Nil { + t.Error("an API key must not impersonate a user") + } + + // TouchAPIKey runs on its own goroutine; give it a moment. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + keys.mu.Lock() + n := len(keys.touched) + keys.mu.Unlock() + if n == 1 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Error("the key's last-used was never recorded") + }) + + t.Run("a revoked key is rejected", func(t *testing.T) { + a, _ := newAuth(APIKey{ID: uuid.New(), OrgID: orgID, Revoked: true}) + + served := false + r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) + r.Header.Set("Authorization", "Bearer "+raw) + w := httptest.NewRecorder() + a.LoadAPIKey(okHandler(&served))(w, r) + + if served || w.Code != http.StatusUnauthorized { + t.Errorf("status=%d served=%v, want 401/false", w.Code, served) + } + }) + + t.Run("an unknown key is rejected", func(t *testing.T) { + a, _ := newAuth(APIKey{ID: uuid.New(), OrgID: orgID}) + + served := false + r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) + r.Header.Set("Authorization", "Bearer not-a-key") + w := httptest.NewRecorder() + a.LoadAPIKey(okHandler(&served))(w, r) + + if served || w.Code != http.StatusUnauthorized { + t.Errorf("status=%d served=%v, want 401/false", w.Code, served) + } + }) + + t.Run("a missing header is rejected", func(t *testing.T) { + a, _ := newAuth(APIKey{ID: uuid.New(), OrgID: orgID}) + + served := false + w := httptest.NewRecorder() + a.LoadAPIKey(okHandler(&served))(w, httptest.NewRequest(http.MethodGet, "/api/external/data", nil)) + + if served || w.Code != http.StatusUnauthorized { + t.Errorf("status=%d served=%v, want 401/false", w.Code, served) + } + }) + + t.Run("the route is closed when no API-key directory is configured", func(t *testing.T) { + a := newHarness(t).auth // no Config.APIKeys + + served := false + r := httptest.NewRequest(http.MethodGet, "/api/external/data", nil) + r.Header.Set("Authorization", "Bearer "+raw) + w := httptest.NewRecorder() + a.LoadAPIKey(okHandler(&served))(w, r) + + if served || w.Code != http.StatusUnauthorized { + t.Errorf("status=%d served=%v, want 401/false", w.Code, served) + } + }) +} + +func TestHashAPIKeyIsStableAndHidesTheKey(t *testing.T) { + hash := HashAPIKey("sk_live_secret") + + if hash != HashAPIKey("sk_live_secret") { + t.Error("HashAPIKey is not deterministic") + } + if hash == HashAPIKey("sk_live_secret2") { + t.Error("distinct keys collided") + } + if strings.Contains(hash, "sk_live_secret") { + t.Error("the digest leaks the raw key") + } + if len(hash) != 64 { + t.Errorf("len = %d, want 64 hex chars of SHA-256", len(hash)) + } +} + +// ---- cookies --------------------------------------------------------------- + +func TestSetSessionCookie(t *testing.T) { + h := newHarness(t) + principal, _, err := h.auth.Login(t.Context(), testUsername, testPassword, SessionMeta{}) + if err != nil { + t.Fatalf("Login: %v", err) + } + + t.Run("without remember-me it dies with the browser", func(t *testing.T) { + w := httptest.NewRecorder() + if err := h.auth.SetSessionCookie(w, httptest.NewRequest(http.MethodPost, "/auth/login", nil), principal, false); err != nil { + t.Fatalf("SetSessionCookie: %v", err) + } + + cookie := w.Result().Cookies()[0] + if cookie.Value != principal.Session.Key { + t.Error("the cookie does not carry the session key") + } + if !cookie.HttpOnly { + t.Error("the session cookie must be HttpOnly") + } + if !cookie.Expires.IsZero() { + t.Error("without remember-me the cookie must be a session cookie") + } + }) + + t.Run("with remember-me it carries the session's expiry", func(t *testing.T) { + w := httptest.NewRecorder() + if err := h.auth.SetSessionCookie(w, httptest.NewRequest(http.MethodPost, "/auth/login", nil), principal, true); err != nil { + t.Fatalf("SetSessionCookie: %v", err) + } + + cookie := w.Result().Cookies()[0] + if !cookie.Expires.Equal(principal.Session.Expiration.Truncate(time.Second)) && + cookie.Expires.Sub(principal.Session.Expiration).Abs() > time.Second { + t.Errorf("Expires = %v, want the session expiration %v", cookie.Expires, principal.Session.Expiration) + } + }) + + t.Run("it fails rather than overflow the domain's cookie budget", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/auth/login", nil) + r.AddCookie(&http.Cookie{Name: "bloat", Value: strings.Repeat("x", maxCookieBytes)}) + + if err := h.auth.SetSessionCookie(httptest.NewRecorder(), r, principal, true); err == nil { + t.Error("SetSessionCookie: nil error, want a failure when the budget is blown") + } + }) +} + +// ---- password policy ------------------------------------------------------- + +func TestCheckPassword(t *testing.T) { + a := newHarness(t, func(c *Config) { + c.Password = PasswordPolicy{ + MinLength: 8, + RequiredUppercase: 1, + RequiredLowercase: 1, + RequiredNumbers: 1, + RequiredSymbols: 1, + } + }).auth + + tests := []struct { + name string + password string + ok bool + }{ + {"satisfies every rule", "Passw0rd!", true}, + {"blank", "", false}, + {"whitespace only", " ", false}, + {"too short", "Pw0!", false}, + {"no uppercase", "passw0rd!", false}, + {"no lowercase", "PASSW0RD!", false}, + {"no number", "Password!", false}, + {"no symbol", "Passw0rdd", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := a.CheckPassword(tt.password) + if tt.ok && err != nil { + t.Errorf("CheckPassword(%q) = %v, want nil", tt.password, err) + } + if !tt.ok && err == nil { + t.Errorf("CheckPassword(%q) = nil, want an error", tt.password) + } + }) + } +} + +func TestZeroPasswordPolicyAcceptsAnyNonBlankPassword(t *testing.T) { + if err := CheckPasswordPolicy(PasswordPolicy{}, "a"); err != nil { + t.Errorf("CheckPasswordPolicy = %v, want nil under the zero policy", err) + } + if err := CheckPasswordPolicy(PasswordPolicy{}, ""); err == nil { + t.Error("CheckPasswordPolicy: a blank password must always be rejected") + } +} + +func TestGeneratePasswordResetToken(t *testing.T) { + first, err := GeneratePasswordResetToken() + if err != nil { + t.Fatalf("GeneratePasswordResetToken: %v", err) + } + if len(first) != 64 { + t.Errorf("len = %d, want 64 hex chars of 32 random bytes", len(first)) + } + + second, err := GeneratePasswordResetToken() + if err != nil { + t.Fatalf("GeneratePasswordResetToken: %v", err) + } + if first == second { + t.Error("two reset tokens came back identical") + } +} + +// ---- context --------------------------------------------------------------- + +func TestPrincipalFromRequestWithoutMiddleware(t *testing.T) { + p := PrincipalFrom(httptest.NewRequest(http.MethodGet, "/", nil)) + if p.Authenticated { + t.Error("a request that never passed through the middleware came back authenticated") + } +} diff --git a/go/auth/authn.go b/go/auth/authn.go new file mode 100644 index 00000000..a49cd5e7 --- /dev/null +++ b/go/auth/authn.go @@ -0,0 +1,346 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + "kjol/security" +) + +// dummyHash is compared against when no user matches, so a login for an unknown +// identifier costs the same bcrypt work as one for a known identifier with the +// wrong password. Without it, response time alone would enumerate valid accounts. +const dummyHash = "$2a$14$KW5OO1wZqGGq3SrpBFj0Oema5DG8Ph7lZJvq0ECkkYBpNFom6b9vO" + +// maxLoginJitter bounds the random delay added to every login. +const maxLoginJitter = 500 * time.Millisecond + +// Login verifies an identifier and password and, on success, mints and persists +// a session. It returns the resulting Principal and the raw session key, which +// is the credential the caller hands to the client — as a cookie via +// SetSessionCookie, or as a bearer token in a JSON response. +// +// Every failure path returns ErrInvalidCredentials: unknown identifier, wrong +// password, and locked-out account are indistinguishable to the caller. +func (a *Authenticator) Login(ctx context.Context, identifier, password string, meta SessionMeta) (Principal, string, error) { + // Partial timing-attack mitigation: a uniform random delay on every attempt. + // + // This does not eliminate the timing signal — an attacker averaging enough + // samples can still separate "user exists, bad password" from "no such user" + // — but it raises the sample count needed by orders of magnitude, which is + // what makes it effective in combination with the lockout below and + // per-IP rate limiting at the edge. + // + // https://security.stackexchange.com/questions/96489/can-i-prevent-timing-attacks-with-random-delays/96493#96493 + jitter, err := rand.Int(rand.Reader, big.NewInt(int64(maxLoginJitter/time.Millisecond))) + if err == nil { + time.Sleep(time.Duration(jitter.Int64()) * time.Millisecond) + } + + user, err := a.dir.FindByIdentifier(ctx, identifier) + lockedOut := a.cfg.MaxLoginAttempts > 0 && user.FailedLoginAttempts > a.cfg.MaxLoginAttempts + + if err != nil || lockedOut { + // Spend the same bcrypt time as the success path before bailing. + security.ComparePasswords(password, dummyHash) + return Principal{}, "", ErrInvalidCredentials + } + + if !security.ComparePasswords(password, user.PasswordHash) { + // Best-effort: a failure to record the attempt must not grant access. + _ = a.dir.RecordLoginResult(ctx, user, false) + return Principal{}, "", ErrInvalidCredentials + } + + if err := a.dir.RecordLoginResult(ctx, user, true); err != nil { + return Principal{}, "", fmt.Errorf("auth: record login: %w", err) + } + + return a.StartSession(ctx, user, meta) +} + +// StartSession mints, persists, and resolves permissions for a session for an +// already-authenticated user. Login calls it after verifying a password; an app +// calls it directly when a user proved themselves some other way (SSO callback, +// an email magic link, impersonation by an admin). +// +// It returns the Principal and the raw session key. +func (a *Authenticator) StartSession(ctx context.Context, user User, meta SessionMeta) (Principal, string, error) { + key, err := security.GenerateRandomKeyBase64(a.cfg.KeyBytes) + if err != nil { + return Principal{}, "", fmt.Errorf("auth: generate session key: %w", err) + } + + now := time.Now() + session := Session{ + // ID is intentionally left zero: the store (or its database default) + // assigns it. + Key: key, + UserID: user.ID, + OrgID: user.OrgID, + Created: now, + Expiration: now.Add(a.cfg.SessionTTL), + Timezone: user.Timezone, + IPAddr: meta.IPAddr, + UserAgent: meta.UserAgent, + } + + if a.cfg.MaxActiveSessions > 0 { + if err := a.store.EnforceSessionLimit(ctx, user.ID, a.cfg.MaxActiveSessions); err != nil { + return Principal{}, "", fmt.Errorf("auth: enforce session limit: %w", err) + } + } + + if err := a.store.Insert(ctx, session); err != nil { + return Principal{}, "", fmt.Errorf("auth: insert session: %w", err) + } + + return Principal{ + Authenticated: true, + Session: session, + Permissions: a.resolvePermissions(ctx, session), + }, key, nil +} + +// Logout revokes the session with the given key. Revoking an already-revoked or +// unknown session is not an error. +func (a *Authenticator) Logout(ctx context.Context, key string) error { + if key == "" { + return nil + } + if err := a.store.Revoke(ctx, key); err != nil { + if errors.Is(err, ErrSessionNotFound) { + return nil + } + return fmt.Errorf("auth: revoke session: %w", err) + } + return nil +} + +// SessionKeyFrom extracts the session key a request presents, preferring an +// Authorization: Bearer token over the session cookie. It returns the key and +// whether it came from a bearer token. +func (a *Authenticator) SessionKeyFrom(r *http.Request) (key string, bearer bool) { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer "), true + } + if c, err := r.Cookie(a.cfg.CookieName); err == nil && c != nil { + return c.Value, false + } + return "", false +} + +// LoadContext resolves the request's session and permissions and attaches the +// Principal to its context. It is the outermost auth middleware; Require and +// friends read what it attached. +// +// With requireAuth false, an unauthenticated request is served anyway, carrying +// the zero Principal — for pages that render differently when signed in. With +// requireAuth true, an unauthenticated request is rejected: JSON 401 for API and +// bearer-token clients, a redirect to LoginPath for browsers (or a bare 401 when +// Config.Redirect is false). +func (a *Authenticator) LoadContext(h http.HandlerFunc, requireAuth bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Every one of these is per-request state. Hoisting any of it into the + // enclosing closure would share it across concurrent requests on this + // route: a single bearer-token request could then disable redirects for + // every browser request that followed. + key, bearer := a.SessionKeyFrom(r) + wantsJSON := bearer || isAPIRequest(r) + redirect := a.cfg.Redirect && !wantsJSON + + session, err := a.lookupSession(r.Context(), key) + if err != nil || !session.Valid() { + expired := err == nil && !session.Revoked && session.Expired() + a.rejectUnauthenticated(w, r, h, requireAuth, wantsJSON, redirect, expired) + return + } + + principal := Principal{ + Authenticated: true, + Session: session, + Permissions: a.resolvePermissions(r.Context(), session), + } + + // An authenticated user asking for the login page gets sent home. + if redirect && r.URL.Path == a.cfg.LoginPath { + http.Redirect(w, r, a.cfg.DefaultPath, http.StatusFound) + return + } + + h(w, withPrincipal(r, principal)) + } +} + +// lookupSession fetches the session for a key, treating an empty key as a miss +// without troubling the store. +func (a *Authenticator) lookupSession(ctx context.Context, key string) (Session, error) { + if key == "" { + return Session{}, ErrSessionNotFound + } + return a.store.FetchByKey(ctx, key) +} + +// rejectUnauthenticated handles a request with no usable session: serve it +// anyway when auth is optional, otherwise reject in whichever dialect the client +// speaks. +func (a *Authenticator) rejectUnauthenticated( + w http.ResponseWriter, r *http.Request, h http.HandlerFunc, + requireAuth, wantsJSON, redirect, expired bool, +) { + if !requireAuth { + h(w, withPrincipal(r, Principal{})) + return + } + + if wantsJSON { + reason := "valid authentication required" + if expired { + reason = "session expired" + } + respondJSONError(w, http.StatusUnauthorized, "Unauthorized: "+reason) + return + } + + if expired { + // The browser is holding a cookie that will never work again. + a.ClearSessionCookie(w) + } + + // Don't bounce the login/logout pages back to themselves. + if redirect && r.URL.Path != a.cfg.LoginPath && r.URL.Path != a.cfg.LogoutPath { + http.Redirect(w, r, a.cfg.LoginPath+"?redirect="+url.QueryEscape(r.URL.String()), http.StatusFound) + return + } + if !redirect && r.URL.Path != a.cfg.LoginPath { + http.Error(w, "Error: Unauthorized", http.StatusUnauthorized) + return + } + + // Unauthenticated, on the login page itself: serve it. + h(w, withPrincipal(r, Principal{})) +} + +// maxCookieBytes is the per-domain cookie budget browsers enforce. +const maxCookieBytes = 4096 + +// SetSessionCookie writes the session key as the app's HttpOnly session cookie. +// Use it for browser form logins; API clients get the raw key from Login instead. +// +// When remember is false the cookie is a session cookie: it dies with the +// browser. When true it carries the session's own expiration. +// +// It fails rather than silently truncating if the domain's other cookies leave +// no room — a session cookie that the browser drops would look like a login that +// mysteriously does nothing. +func (a *Authenticator) SetSessionCookie(w http.ResponseWriter, r *http.Request, p Principal, remember bool) error { + var otherBytes int + for _, c := range r.Cookies() { + if c.Name != a.cfg.CookieName { + otherBytes += len(c.Value) + } + } + + // The 8 bytes are slack for cookie framing overhead. + if len(p.Session.Key)+otherBytes+8 > maxCookieBytes { + return fmt.Errorf("auth: cannot set session cookie, domain cookies exceed %d bytes", maxCookieBytes) + } + + cookie := &http.Cookie{ + Name: a.cfg.CookieName, + Value: p.Session.Key, + HttpOnly: true, + Path: "/", + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + } + if remember { + cookie.Expires = p.Session.Expiration + } + + http.SetCookie(w, cookie) + return nil +} + +// ClearSessionCookie expires the session cookie in the client. It does not +// revoke the session server-side — pair it with Logout. +func (a *Authenticator) ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: a.cfg.CookieName, + Value: "", + MaxAge: -1, + Expires: time.Unix(1, 0), + Path: "/", + }) +} + +// HashAPIKey returns the digest under which an API key is stored and looked up. +// Only the digest is ever persisted, so a leaked database yields no usable keys. +func HashAPIKey(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// LoadAPIKey authenticates a request with an organization-scoped API key from +// Authorization: Bearer, and attaches an API-key Principal. It is the +// non-interactive counterpart to LoadContext, for machine-facing endpoints. +// +// The Principal carries the key's org but no user and no permissions. Routes +// mounted behind this middleware are therefore gated by the mount itself, not by +// Require — which would deny them, there being no permissions to match. +// +// Requires Config.APIKeys; without it every request is rejected. +func (a *Authenticator) LoadAPIKey(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.cfg.APIKeys == nil { + respondJSONError(w, http.StatusUnauthorized, "Unauthorized: API key authentication is not configured") + return + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + respondJSONError(w, http.StatusUnauthorized, "Unauthorized: API key required") + return + } + + key, err := a.cfg.APIKeys.FindAPIKeyByHash(r.Context(), HashAPIKey(strings.TrimPrefix(authHeader, "Bearer "))) + if err != nil || key.Revoked { + respondJSONError(w, http.StatusUnauthorized, "Unauthorized: invalid or revoked API key") + return + } + + // Last-used is bookkeeping: don't make the caller wait on it, and don't + // let the request's cancellation abort the write. + go a.cfg.APIKeys.TouchAPIKey(context.WithoutCancel(r.Context()), key.ID) + + h(w, withPrincipal(r, Principal{ + Authenticated: true, + IsAPIKey: true, + Session: Session{OrgID: &key.OrgID}, + Permissions: map[string]bool{}, + })) + } +} + +// isAPIRequest reports whether a request should be answered in JSON rather than +// with a redirect. +func isAPIRequest(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, "/api/") +} + +// respondJSONError writes a JSON error body. The message is engine-authored, so +// it needs no escaping. +func respondJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprintf(w, `{"error": %q}`, message) +} diff --git a/go/auth/authz.go b/go/auth/authz.go new file mode 100644 index 00000000..6ef08498 --- /dev/null +++ b/go/auth/authz.go @@ -0,0 +1,153 @@ +package auth + +import ( + "net/http" + "time" + + . "kjol/basic" + "kjol/l4g" +) + +// Permissions are plain strings, aggregated per request by the app's +// PermissionResolver into a single map. The engine does not care where they come +// from — a user's direct grants, the groups they belong to, an organization +// role, a claim snapshot from an SSO login — only whether the map contains what +// a route requires. +// +// Two families of check live here, and the difference matters: +// +// - Authorized / AuthorizedExact / AuthorizedAny are pure predicates, for +// display logic. They are expected to fail routinely (most users are not +// admins, and every "admin only" menu asks on every page), so they log +// nothing. +// +// - Require / RequireExact / RequireAny are middleware. A failure here means a +// request actually reached a route the caller may not have, so it is logged. +// +// The pairing is deliberate: gate the link with Authorized so the request is +// never made, and gate the route with Require so it cannot be made anyway. + +// Authorized reports whether the principal holds every listed permission. +// The super permission satisfies any requirement. +func (a *Authenticator) Authorized(p Principal, requirements ...string) bool { + if a.hasSuper(p) { + return true + } + return holdsAll(p, requirements) +} + +// AuthorizedExact reports whether the principal holds every listed permission, +// with no super-permission bypass. +// +// Use it where the question is "does this user really have X" rather than "may +// this user do X" — e.g. showing an admin their own effective grants, where the +// wildcard would otherwise make every box look ticked. +func (a *Authenticator) AuthorizedExact(p Principal, requirements ...string) bool { + return holdsAll(p, requirements) +} + +// AuthorizedAny reports whether the principal holds at least one of the listed +// permissions. The super permission satisfies it. +// +// This is for resources read by several pages whose own permissions differ — a +// product-options list fetched by both the inventory editor and the sales +// terminal — where requiring all of them would lock out callers who legitimately +// hold only one. +func (a *Authenticator) AuthorizedAny(p Principal, anyOf ...string) bool { + if a.hasSuper(p) { + return true + } + for _, requirement := range anyOf { + if p.Permissions[requirement] { + return true + } + } + return false +} + +// Require gates a handler on the caller holding every listed permission, +// answering 403 otherwise. Mount it inside LoadContext. +func (a *Authenticator) Require(h http.HandlerFunc, requirements ...string) http.HandlerFunc { + return a.gate(h, func(p Principal) bool { return a.Authorized(p, requirements...) }, requirements) +} + +// RequireExact gates a handler on the caller holding every listed permission, +// with no super-permission bypass. +func (a *Authenticator) RequireExact(h http.HandlerFunc, requirements ...string) http.HandlerFunc { + return a.gate(h, func(p Principal) bool { return a.AuthorizedExact(p, requirements...) }, requirements) +} + +// RequireAny gates a handler on the caller holding at least one of the listed +// permissions. +func (a *Authenticator) RequireAny(h http.HandlerFunc, anyOf ...string) http.HandlerFunc { + return a.gate(h, func(p Principal) bool { return a.AuthorizedAny(p, anyOf...) }, anyOf) +} + +// gate is the shared middleware body: check, log-and-deny, or serve. +func (a *Authenticator) gate(h http.HandlerFunc, allow func(Principal) bool, requirements []string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r) + + if !allow(p) { + a.logDenial(r, p, requirements) + if isAPIRequest(r) { + respondJSONError(w, http.StatusForbidden, "Forbidden: insufficient permissions") + return + } + w.WriteHeader(http.StatusForbidden) + return + } + + h(w, r) + } +} + +// logDenial records a failed route authorization. +// +// Unlike a failed display check, this means a request was actually issued for a +// route the caller cannot have: either a user is missing access they need, or +// someone is probing. Both are worth seeing, so this is the one authorization +// path that logs. +func (a *Authenticator) logDenial(r *http.Request, p Principal, requirements []string) { + entry := l4g.Entry{ + Category: l4g.CATEGORY_AUTH, + LogType: l4g.TYPE_WARN, + Content: MakePtr("Failed Endpoint Authorization"), + StructuredContent: l4g.Serialize(struct { + Route string + RequiredPermissions []string + CurrentPermissions map[string]bool + Authenticated bool + }{ + Route: r.URL.Path, + RequiredPermissions: requirements, + CurrentPermissions: p.Permissions, + Authenticated: p.Authenticated, + }), + Timestamp: time.Now(), + } + + // Anonymous denials carry no user to attribute the entry to. + if p.Authenticated { + entry.AppUserID = MakePtr(p.Session.UserID) + entry.OrgID = p.Session.OrgID + entry.IdentityID = MakePtr(p.Session.ID) + } + + l4g.Write(entry) +} + +// hasSuper reports whether the principal holds the app's wildcard permission. +func (a *Authenticator) hasSuper(p Principal) bool { + return a.cfg.SuperPermission != "" && p.Permissions[a.cfg.SuperPermission] +} + +// holdsAll reports whether the principal holds every listed permission. +func holdsAll(p Principal, requirements []string) bool { + for _, requirement := range requirements { + if !p.Permissions[requirement] { + return false + } + } + return true +} diff --git a/go/auth/password.go b/go/auth/password.go new file mode 100644 index 00000000..3e62039f --- /dev/null +++ b/go/auth/password.go @@ -0,0 +1,67 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strconv" + "strings" + "unicode" +) + +// CheckPassword reports whether a password satisfies the configured policy, +// returning an error phrased for the end user (the caller can surface it +// verbatim on a signup or change-password form). +func (a *Authenticator) CheckPassword(password string) error { + return CheckPasswordPolicy(a.cfg.Password, password) +} + +// CheckPasswordPolicy is CheckPassword against an explicit policy, for callers +// that validate a password without an Authenticator in hand. +func CheckPasswordPolicy(policy PasswordPolicy, password string) error { + if strings.TrimSpace(password) == "" { + return errors.New("Password cannot be blank.") + } + + if len(password) < policy.MinLength { + return errors.New("Password must be at least " + strconv.Itoa(policy.MinLength) + " characters long.") + } + + var uppercase, lowercase, numbers, symbols int + for _, r := range password { + switch { + case unicode.IsUpper(r): + uppercase++ + case unicode.IsLower(r): + lowercase++ + case unicode.IsNumber(r): + numbers++ + default: + symbols++ + } + } + + switch { + case uppercase < policy.RequiredUppercase: + return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredUppercase) + " uppercase character(s).") + case lowercase < policy.RequiredLowercase: + return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredLowercase) + " lowercase character(s).") + case numbers < policy.RequiredNumbers: + return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredNumbers) + " number(s).") + case symbols < policy.RequiredSymbols: + return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredSymbols) + " symbol(s).") + } + + return nil +} + +// GeneratePasswordResetToken returns a 32-byte crypto-random token, hex-encoded +// (64 characters). The app stores it against the user with an expiry and mails +// the same string out. +func GeneratePasswordResetToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/go/jsbundler/faicons.go b/go/jsbundler/faicons.go index 8158771d..a9dfe2bc 100644 --- a/go/jsbundler/faicons.go +++ b/go/jsbundler/faicons.go @@ -31,8 +31,12 @@ var faStyleDirs = map[string]string{ var ( // icon="name" / icon: "name" reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`) - // icon={ ... } — dynamic expressions; pull any string literals (ternaries etc.) - reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\{([^}]*)\}`) + // icon={ ... } — a dynamic expression; pull any string literals out of it + // (ternaries etc.). The `$` is optional because both authoring styles in this + // codebase have to be seen: JSX writes icon={"bell"}, while a solid-js/html + // tagged template writes icon=${"bell"}. Missing the second form drops the + // icon from the registry silently — it simply renders blank at runtime. + reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\$?\{([^}]*)\}`) reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`) reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`) rePathD = regexp.MustCompile(`]*\bd="([^"]+)"`) diff --git a/go/jsbundler/faicons_test.go b/go/jsbundler/faicons_test.go new file mode 100644 index 00000000..86fd22fc --- /dev/null +++ b/go/jsbundler/faicons_test.go @@ -0,0 +1,70 @@ +package jsbundler + +import ( + "os" + "path/filepath" + "testing" +) + +// scanIconNames has to see every authoring style used across the apps. Both are +// live in the same file in practice: a page written as a solid-js/html tagged +// template says icon=${"bell"}, while a .tsx page says icon={"bell"} or +// icon="bell". An icon the scanner misses is not a build error — it is simply +// absent from the generated registry and renders as a blank space at runtime, +// which is exactly how a missing bell/circle-question went unnoticed. +func TestScanIconNamesAcrossAuthoringStyles(t *testing.T) { + dir := t.TempDir() + + src := ` + // plain attribute (JSX or template) + + + // JSX dynamic expression + + + // solid-js/html tagged template — interpolated string + <${Icon} icon=${"bell"} size=${28} /> + <${Icon} icon=${"circle-question"} size=${28} /> + + // object-literal property + const item = { icon: "chart-line", label: "Reports" }; + + // dynamic expressions: every string literal inside is a candidate + + <${Icon} icon=${busy ? "spinner" : "check"} /> + + // an in-app custom icon + registerIcon("playground", ""); + ` + if err := os.WriteFile(filepath.Join(dir, "page.tsx"), []byte(src), 0644); err != nil { + t.Fatal(err) + } + + names, custom, err := scanIconNames([]string{dir}) + if err != nil { + t.Fatalf("scanIconNames: %v", err) + } + + found := make(map[string]bool, len(names)) + for _, n := range names { + found[n] = true + } + + for _, want := range []string{ + "notebook", // icon="name" + "gear", // icon={"name"} + "bell", // icon=${"name"} <- the regression + "circle-question", // icon=${"name"} <- the regression + "chart-line", // icon: "name" + "chevron-up", "chevron-down", // JSX ternary + "spinner", "check", // template-literal ternary + } { + if !found[want] { + t.Errorf("icon %q was not scanned; it would render blank at runtime", want) + } + } + + if !custom["playground"] { + t.Error("registerIcon(\"playground\") was not picked up as a custom icon") + } +}