347 lines
12 KiB
Go
347 lines
12 KiB
Go
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)
|
|
}
|