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