568 lines
23 KiB
Go
568 lines
23 KiB
Go
// Package login is the OIDC sign-in flow (P-6). The identity provider supplies the EVENT of a
|
|
// login and nothing else: its tokens are used once inside the callback to prove who the caller is
|
|
// and are then dropped — nothing vendor-issued is persisted or outlives the request. The session
|
|
// that follows is ours (D39.84), which is what keeps instant revocation and token accounting.
|
|
package login
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/time/rate"
|
|
|
|
"textmachine/platform/internal/auth"
|
|
"textmachine/platform/internal/reqid"
|
|
)
|
|
|
|
// stateTTL is how long an authorization round trip may take. Long enough for a consent screen,
|
|
// short enough that an abandoned login is not a standing row.
|
|
const stateTTL = 10 * time.Minute
|
|
|
|
// Config is the provider and the policy around it.
|
|
type Config struct {
|
|
// Provider is the key stored in identities.provider ("google"). It is OUR name for the issuer,
|
|
// not the issuer's, so a provider that changes its URL does not orphan its accounts.
|
|
Provider string
|
|
// Issuer is the OIDC issuer URL; everything else is discovered from it.
|
|
Issuer string
|
|
ClientID string
|
|
ClientSecret string
|
|
// RedirectURL must match the one registered with the provider, exactly.
|
|
RedirectURL string
|
|
Scopes []string
|
|
// AfterLogin is where the browser lands when the request did not ask for somewhere else.
|
|
AfterLogin string
|
|
// SessionIdleTTL and SessionMaxAge are the session's two clocks.
|
|
SessionIdleTTL time.Duration
|
|
SessionMaxAge time.Duration
|
|
// SignupGrantMicroUSD is the credit written when an account is created.
|
|
SignupGrantMicroUSD int64
|
|
// StartRate bounds how fast unauthenticated callers can make us write state rows. The login
|
|
// endpoint is the first surface a bot finds.
|
|
StartRate rate.Limit
|
|
StartBurst int
|
|
}
|
|
|
|
// Handler serves /auth/*.
|
|
type Handler struct {
|
|
cfg Config
|
|
store Store
|
|
cookies auth.Cookies
|
|
log *slog.Logger
|
|
limiter *rate.Limiter
|
|
now func() time.Time
|
|
|
|
// httpClient is used for every provider round trip and is NEVER nil — New always installs a
|
|
// bounded one, and tests replace it with their own. That is not tidiness: our per-request
|
|
// deadline cannot reach the requests go-oidc makes on its own schedule, because Provider.Verifier
|
|
// uses the key set built at discovery and go-oidc stores it with context.WithoutCancel. Left to
|
|
// itself that refetch runs on http.DefaultClient, which has no timeout, and one JWKS fetch that
|
|
// hangs then keeps every later sign-in failing after the endpoint recovers — they queue on the
|
|
// same inflight fetch. NewProvider captures this client, which is what bounds them.
|
|
httpClient *http.Client
|
|
// exchangeTimeout overrides providerTimeout. A test seam, like httpClient and now: the property
|
|
// under test is that the bound EXISTS, and waiting the production ten seconds to see it would
|
|
// make the battery slower without making it stricter.
|
|
exchangeTimeout time.Duration
|
|
|
|
// Discovery is lazy and cached: a provider that is unreachable at boot must not stop the
|
|
// service from starting, and its outage must read as "login is temporarily unavailable"
|
|
// rather than as a crash loop.
|
|
mu sync.Mutex
|
|
provider *oidc.Provider
|
|
// issSupported is the discovery document's authorization_response_iss_parameter_supported.
|
|
// Cached with the provider because RFC 9207 §2.4 makes an ABSENT iss a refusal exactly when the
|
|
// server is known to send one, and "known" here means what discovery said.
|
|
issSupported bool
|
|
|
|
failFn Fail
|
|
}
|
|
|
|
// New builds the handler. It performs no network I/O.
|
|
func New(cfg Config, store Store, cookies auth.Cookies, log *slog.Logger) (*Handler, error) {
|
|
switch {
|
|
case cfg.Provider == "":
|
|
return nil, errors.New("login: no provider name")
|
|
case cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "":
|
|
return nil, errors.New("login: issuer, client id and client secret are all required")
|
|
case cfg.RedirectURL == "":
|
|
return nil, errors.New("login: no redirect url")
|
|
}
|
|
if cfg.AfterLogin == "" {
|
|
cfg.AfterLogin = "/"
|
|
}
|
|
if len(cfg.Scopes) == 0 {
|
|
cfg.Scopes = []string{oidc.ScopeOpenID, "email", "profile"}
|
|
}
|
|
if cfg.StartRate == 0 {
|
|
cfg.StartRate, cfg.StartBurst = 2, 20
|
|
}
|
|
if cfg.StartBurst <= 0 {
|
|
// rate.NewLimiter with a zero burst allows nothing at all: a caller who set the rate and
|
|
// forgot the burst would turn every sign-in into a 429 and read it as a broken provider.
|
|
cfg.StartBurst = 1
|
|
}
|
|
return &Handler{
|
|
cfg: cfg,
|
|
store: store,
|
|
cookies: cookies,
|
|
log: log,
|
|
limiter: rate.NewLimiter(cfg.StartRate, cfg.StartBurst),
|
|
now: time.Now,
|
|
httpClient: &http.Client{Timeout: providerTimeout},
|
|
}, nil
|
|
}
|
|
|
|
// Routes mounts the flow. guard is the session middleware: starting a login must be reachable
|
|
// without one, ending a login must not be.
|
|
func (h *Handler) Routes(guard func(http.Handler) http.Handler) http.Handler {
|
|
mux := http.NewServeMux()
|
|
// The method lives in a wrapper rather than in the pattern so that a wrong one answers in
|
|
// problem+json like everything else: ServeMux's own 405 is text/plain, and the contract
|
|
// admits one error shape.
|
|
mux.Handle("/auth/login", h.only(http.MethodGet, http.HandlerFunc(h.start)))
|
|
mux.Handle("/auth/callback", h.only(http.MethodGet, http.HandlerFunc(h.callback)))
|
|
mux.Handle("/auth/logout", h.only(http.MethodPost, guard(http.HandlerFunc(h.logout))))
|
|
mux.Handle("/auth/logout-all", h.only(http.MethodPost, guard(http.HandlerFunc(h.logoutAll))))
|
|
mux.Handle("/auth/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h.fail(w, http.StatusNotFound, "Object not found", "")
|
|
}))
|
|
return mux
|
|
}
|
|
|
|
func (h *Handler) only(method string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != method {
|
|
w.Header().Set("Allow", method)
|
|
h.fail(w, http.StatusMethodNotAllowed, "Method not allowed", "")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// start begins the authorization code flow with PKCE.
|
|
func (h *Handler) start(w http.ResponseWriter, r *http.Request) {
|
|
if !h.limiter.Allow() {
|
|
// Rate limiting an unauthenticated endpoint that WRITES is not optional: every call here
|
|
// costs a row, and the caller has not proven anything yet.
|
|
//
|
|
// Deliberately GLOBAL rather than per-address. There is no trusted edge proxy defined yet,
|
|
// so RemoteAddr behind one is the proxy's address for everybody — a per-address limiter
|
|
// would lock out every user at once the moment it fired. Per-address belongs at the edge,
|
|
// with the header trust that only the edge can establish.
|
|
w.Header().Set("Retry-After", "5")
|
|
// The limiter is the only thing between an unauthenticated writer and the state table, so
|
|
// its engagement is an event, not a detail.
|
|
h.log.WarnContext(r.Context(), "sign-in rate limit engaged", "provider", h.cfg.Provider)
|
|
h.fail(w, http.StatusTooManyRequests, "Too many login attempts", "")
|
|
return
|
|
}
|
|
provider, err := h.discover(r.Context())
|
|
if err != nil {
|
|
h.log.ErrorContext(r.Context(), "oidc discovery failed", "err", err, "provider", h.cfg.Provider)
|
|
h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "")
|
|
return
|
|
}
|
|
|
|
state := auth.NewToken()
|
|
nonce := auth.NewToken()
|
|
verifier := oauth2.GenerateVerifier()
|
|
st := State{
|
|
Hash: auth.Digest(state),
|
|
Provider: h.cfg.Provider,
|
|
// The issuer this request is being sent to. oidc.NewProvider refuses a discovery document
|
|
// whose issuer differs from the URL it was fetched from, so the configured value is the
|
|
// discovered one.
|
|
Issuer: h.cfg.Issuer,
|
|
// The two halves of a sign-in are two requests with two request ids. This is what joins
|
|
// them in the log without putting anything about the user in it.
|
|
StartID: reqid.FromContext(r.Context()),
|
|
Nonce: nonce,
|
|
Verifier: verifier,
|
|
ReturnTo: safeReturnTo(r.URL.Query().Get("return_to")),
|
|
CreatedAt: h.now(),
|
|
ExpiresAt: h.now().Add(stateTTL),
|
|
}
|
|
if err := h.store.PutLoginState(r.Context(), st); err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot store login state", "err", err)
|
|
h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "")
|
|
return
|
|
}
|
|
|
|
h.cookies.SetLogin(w, state, stateTTL)
|
|
cfg := h.oauth(provider)
|
|
url := cfg.AuthCodeURL(state,
|
|
oidc.Nonce(nonce),
|
|
oauth2.S256ChallengeOption(verifier),
|
|
oauth2.AccessTypeOnline,
|
|
)
|
|
http.Redirect(w, r, url, http.StatusSeeOther)
|
|
}
|
|
|
|
// callback finishes it. Every failure below is the same to the caller and distinct in the journal.
|
|
func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
|
|
h.cookies.ClearLogin(w) // whatever happens, this round trip is over
|
|
|
|
// The callback WRITES a journal row on every refusal and needs no credential to do it. Without
|
|
// its own limit it is a free, unauthenticated way to grow a table: measured at ~880 rows/s from
|
|
// one host before this existed.
|
|
if !h.limiter.Allow() {
|
|
w.Header().Set("Retry-After", "5")
|
|
h.fail(w, http.StatusTooManyRequests, "Too many sign-in attempts", "")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
if e := q.Get("error"); e != "" {
|
|
// The user declined, or the provider refused. Not our error, still an event.
|
|
h.deny(w, r, "provider_error:"+sanitize(e))
|
|
return
|
|
}
|
|
state, code := q.Get("state"), q.Get("code")
|
|
cookie, err := r.Cookie(h.cookies.LoginName())
|
|
if err != nil || state == "" || code == "" {
|
|
h.deny(w, r, "missing_state")
|
|
return
|
|
}
|
|
// The cookie proves the callback came back to the browser that started: without it, an attacker
|
|
// can hand a victim a link that logs the victim into the ATTACKER's account.
|
|
if subtle.ConstantTimeCompare([]byte(state), []byte(cookie.Value)) != 1 {
|
|
h.deny(w, r, "state_mismatch")
|
|
return
|
|
}
|
|
st, err := h.store.TakeLoginState(r.Context(), auth.Digest(state), h.now())
|
|
if err != nil {
|
|
h.deny(w, r, "unknown_state") // expired, already used, or never issued
|
|
return
|
|
}
|
|
// The state names the provider that issued it. With one provider this is a tautology; with two
|
|
// it is what stops a state minted by one being redeemed at the other — the IdP mix-up class.
|
|
if st.Provider != h.cfg.Provider {
|
|
h.deny(w, r, "state_from_another_provider")
|
|
return
|
|
}
|
|
|
|
provider, err := h.discover(r.Context())
|
|
if err != nil {
|
|
h.deny(w, r, "discovery_failed")
|
|
return
|
|
}
|
|
if reason := h.checkIssuer(q.Get("iss"), st); reason != "" {
|
|
h.deny(w, r, reason)
|
|
return
|
|
}
|
|
claims, err := h.identify(r.Context(), provider, code, st)
|
|
if err != nil {
|
|
h.log.ErrorContext(r.Context(), "identity could not be established", "err", err,
|
|
"provider", h.cfg.Provider, "login_start_id", st.StartID)
|
|
// A provider we could not reach is an outage; a token we refused is a rejection. Reported
|
|
// as one thing, a provider outage reads as a storm of bad tokens.
|
|
reason := "token_rejected"
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) || errors.Is(err, syscall.ECONNREFUSED) {
|
|
reason = "provider_unreachable"
|
|
}
|
|
h.deny(w, r, reason)
|
|
return
|
|
}
|
|
|
|
// The signup credit goes only to an identity the provider vouched for. Without that condition a
|
|
// provider that lets anyone self-register turns every new subject into free credit, bounded only
|
|
// by the rate limiter; an unverified account is still created, just at zero, and an operator can
|
|
// grant it by hand.
|
|
grant := h.cfg.SignupGrantMicroUSD
|
|
if !claims.EmailVerified {
|
|
grant = 0
|
|
}
|
|
userID, err := h.store.UpsertIdentity(r.Context(), Identity{
|
|
Provider: h.cfg.Provider,
|
|
Subject: claims.Subject,
|
|
Email: claims.Email,
|
|
EmailVerified: claims.EmailVerified,
|
|
}, h.now(), grant)
|
|
if err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot bind identity", "err", err)
|
|
h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "")
|
|
return
|
|
}
|
|
|
|
// Session fixation: whatever session this browser was carrying, it does not carry it out of a
|
|
// login. The new session is a new secret, and the old one is dead rather than merely replaced.
|
|
if old, _, ok := auth.Present(r, h.cookies.SessionName()); ok {
|
|
if err := h.store.RevokeSession(r.Context(), auth.Digest(old), h.now()); err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot revoke the pre-login session", "err", err)
|
|
}
|
|
}
|
|
token := auth.NewToken()
|
|
if err := h.store.CreateSession(r.Context(), auth.Digest(token), userID, h.now(),
|
|
h.cfg.SessionIdleTTL, h.cfg.SessionMaxAge); err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot create session", "err", err)
|
|
h.fail(w, http.StatusServiceUnavailable, "Sign-in is temporarily unavailable", "")
|
|
return
|
|
}
|
|
// The cookie lives as long as the IDLE window, not the absolute one: a cookie that outlives the
|
|
// session it names makes every request after the timeout a 401 instead of a clean signed-out
|
|
// state.
|
|
h.cookies.SetSession(w, token, h.cfg.SessionIdleTTL)
|
|
h.record(r, LoginEvent{UserID: userID, Provider: h.cfg.Provider, Outcome: "success"})
|
|
// Without this the log has two 303s and no sign that anyone signed in. No account id: user ids
|
|
// do not go to logs (ENGINEERING_STANDARDS §2) — the journal table is where "who" is answered.
|
|
h.log.InfoContext(r.Context(), "login succeeded", "provider", h.cfg.Provider,
|
|
"login_start_id", st.StartID, "client", clientClass(r.UserAgent()))
|
|
|
|
dest := st.ReturnTo
|
|
if dest == "" {
|
|
dest = h.cfg.AfterLogin
|
|
}
|
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
|
}
|
|
|
|
// logout ends this session.
|
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
|
if token, _, ok := auth.Present(r, h.cookies.SessionName()); ok {
|
|
if err := h.store.RevokeSession(r.Context(), auth.Digest(token), h.now()); err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot revoke session", "err", err)
|
|
h.fail(w, http.StatusServiceUnavailable, "Could not sign out", "")
|
|
return
|
|
}
|
|
}
|
|
h.cookies.ClearSession(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// logoutAll ends every session of this user: the handle behind "sign out everywhere".
|
|
func (h *Handler) logoutAll(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := auth.FromContext(r.Context())
|
|
if !ok {
|
|
h.fail(w, http.StatusUnauthorized, "Session missing or invalid", "")
|
|
return
|
|
}
|
|
n, err := h.store.RevokeUserSessions(r.Context(), p.UserID, h.now())
|
|
if err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot revoke sessions", "err", err)
|
|
h.fail(w, http.StatusServiceUnavailable, "Could not sign out", "")
|
|
return
|
|
}
|
|
h.log.InfoContext(r.Context(), "all sessions revoked", "count", n)
|
|
h.cookies.ClearSession(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// claims is the only part of the identity token we keep.
|
|
type claims struct {
|
|
Subject string `json:"sub"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
}
|
|
|
|
// identify exchanges the code and verifies the identity token. Everything the provider issued dies
|
|
// with this function: no access token, no refresh token, no raw JWT leaves it.
|
|
func (h *Handler) identify(ctx context.Context, provider *oidc.Provider, code string, st State) (claims, error) {
|
|
// Bounds how long ONE caller waits. The client's own timeout (see httpClient) bounds the
|
|
// requests; this bounds the wait, including the wait on a key fetch another sign-in started.
|
|
// Without it the handler is held for as long as the browser keeps its socket, because the
|
|
// server sets no WriteTimeout by design.
|
|
bound := providerTimeout
|
|
if h.exchangeTimeout > 0 {
|
|
bound = h.exchangeTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, bound)
|
|
defer cancel()
|
|
ctx = oidc.ClientContext(ctx, h.httpClient)
|
|
tok, err := h.oauth(provider).Exchange(ctx, code, oauth2.VerifierOption(st.Verifier))
|
|
if err != nil {
|
|
return claims{}, fmt.Errorf("code exchange: %w", err)
|
|
}
|
|
raw, ok := tok.Extra("id_token").(string)
|
|
if !ok {
|
|
return claims{}, errors.New("no id_token in the token response")
|
|
}
|
|
idToken, err := provider.Verifier(&oidc.Config{ClientID: h.cfg.ClientID}).Verify(ctx, raw)
|
|
if err != nil {
|
|
return claims{}, fmt.Errorf("id token: %w", err)
|
|
}
|
|
// The nonce ties this token to OUR authorization request; without it a token minted for another
|
|
// request of the same client is replayable here.
|
|
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(st.Nonce)) != 1 {
|
|
return claims{}, errors.New("id token nonce does not match the request")
|
|
}
|
|
var c claims
|
|
if err := idToken.Claims(&c); err != nil {
|
|
return claims{}, fmt.Errorf("id token claims: %w", err)
|
|
}
|
|
if c.Subject == "" {
|
|
return claims{}, errors.New("id token carries no subject")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (h *Handler) oauth(provider *oidc.Provider) *oauth2.Config {
|
|
return &oauth2.Config{
|
|
ClientID: h.cfg.ClientID,
|
|
ClientSecret: h.cfg.ClientSecret,
|
|
Endpoint: provider.Endpoint(),
|
|
RedirectURL: h.cfg.RedirectURL,
|
|
Scopes: h.cfg.Scopes,
|
|
}
|
|
}
|
|
|
|
// Bounds on the two provider round trips this flow makes while a user waits. http.DefaultClient has
|
|
// no timeout of its own, so without these a stalled issuer holds a handler indefinitely.
|
|
const (
|
|
discoveryTimeout = 5 * time.Second
|
|
// providerTimeout covers the code exchange AND the identity-token verification, which may fetch
|
|
// the signing keys. Longer than discovery because it is two round trips, not one.
|
|
providerTimeout = 10 * time.Second
|
|
)
|
|
|
|
func (h *Handler) discover(ctx context.Context) (*oidc.Provider, error) {
|
|
h.mu.Lock()
|
|
cached := h.provider
|
|
h.mu.Unlock()
|
|
if cached != nil {
|
|
return cached, nil
|
|
}
|
|
// The fetch happens WITHOUT the lock. Holding it across the network call serialises every
|
|
// sign-in behind one slow provider: six concurrent requests against a 4-second issuer took
|
|
// 4, 8, 12, 16, 20 and 24 seconds instead of four.
|
|
ctx, cancel := context.WithTimeout(ctx, discoveryTimeout)
|
|
defer cancel()
|
|
// Passed unconditionally, and this is the load-bearing call: NewProvider captures the client and
|
|
// the key set it builds keeps using it, long after this context is gone.
|
|
ctx = oidc.ClientContext(ctx, h.httpClient)
|
|
p, err := oidc.NewProvider(ctx, h.cfg.Issuer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var flags struct {
|
|
IssSupported bool `json:"authorization_response_iss_parameter_supported"`
|
|
}
|
|
// A document that does not carry the field simply leaves it false. A document that carries it
|
|
// with the wrong type errors — and that case is NOT silent, because it quietly disables the
|
|
// stripped-parameter half of the mix-up check (RFC 9207 §2.4) and would otherwise look like a
|
|
// provider that simply does not support iss. Refusing sign-in over it would be worse.
|
|
if err := p.Claims(&flags); err != nil {
|
|
h.log.WarnContext(ctx, "discovery document did not parse; assuming no iss parameter support",
|
|
"err", err, "provider", h.cfg.Provider)
|
|
}
|
|
h.mu.Lock()
|
|
if h.provider == nil {
|
|
h.provider, h.issSupported = p, flags.IssSupported
|
|
}
|
|
cached = h.provider
|
|
h.mu.Unlock()
|
|
return cached, nil
|
|
}
|
|
|
|
// checkIssuer applies RFC 9207 §2.4 to the authorization response: the server that answered must be
|
|
// the one the request was sent to. It returns the journal reason for a refusal, or "".
|
|
//
|
|
// RFC 9700 §4.4.2 requires a mix-up defence only from the SECOND authorization server onwards, and
|
|
// there is one today. It is here anyway because the alternative is discovering, at the moment a
|
|
// second provider is configured, that the comparison in this handler was configuration compared
|
|
// with itself (PD-57) — and because a state carrying a different issuer than the one now configured
|
|
// is a redeploy mid-login, which this refuses rather than redeems.
|
|
func (h *Handler) checkIssuer(got string, st State) string {
|
|
if got != "" {
|
|
// "Simple string comparison" with the stored issuer, per RFC 9207 §2.4. A state written
|
|
// before the issuer column existed carries "" and is refused: those rows live ten minutes,
|
|
// so the upgrade window costs a retry, and failing open on an identity check costs more.
|
|
if got != st.Issuer {
|
|
return "issuer_mismatch"
|
|
}
|
|
return ""
|
|
}
|
|
h.mu.Lock()
|
|
supported := h.issSupported
|
|
h.mu.Unlock()
|
|
if supported {
|
|
// The parameter was stripped. RFC 9207 §2.4: clients MUST reject a response with no iss
|
|
// from a server that does send one.
|
|
return "issuer_missing"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// deny is a refused login: one shape on the wire, one row in the journal with the reason.
|
|
func (h *Handler) deny(w http.ResponseWriter, r *http.Request, reason string) {
|
|
h.record(r, LoginEvent{Provider: h.cfg.Provider, Outcome: "denied", Reason: reason})
|
|
h.log.WarnContext(r.Context(), "login denied", "reason", reason, "provider", h.cfg.Provider)
|
|
h.fail(w, http.StatusBadRequest, "Sign-in could not be completed", "")
|
|
}
|
|
|
|
func (h *Handler) record(r *http.Request, ev LoginEvent) {
|
|
ev.At = h.now()
|
|
ev.IPPrefix = ipPrefix(r.RemoteAddr)
|
|
ev.Client = clientClass(r.UserAgent())
|
|
// The journal must not decide whether a login succeeds: a failure to write it is logged and the
|
|
// login proceeds. Losing an audit line is bad; refusing a legitimate sign-in is worse.
|
|
if err := h.store.RecordLogin(r.Context(), ev); err != nil {
|
|
h.log.ErrorContext(r.Context(), "cannot record the login event", "err", err)
|
|
}
|
|
}
|
|
|
|
// Fail writes the error body; the API layer installs it. No *http.Request: nothing in this flow
|
|
// needs one to write a problem, and the parameter existed only to make httpapi.WriteProblem not fit,
|
|
// which cost a forwarding shim (found by review).
|
|
type Fail func(w http.ResponseWriter, status int, title, detail string)
|
|
|
|
// SetFail installs the error writer. Without it the handler answers in plain text.
|
|
func (h *Handler) SetFail(f Fail) { h.failFn = f }
|
|
|
|
func (h *Handler) fail(w http.ResponseWriter, status int, title, detail string) {
|
|
if h.failFn != nil {
|
|
h.failFn(w, status, title, detail)
|
|
return
|
|
}
|
|
http.Error(w, title, status)
|
|
}
|
|
|
|
// safeReturnTo accepts only a path on this site. An open redirect turns our own login link into a
|
|
// phishing tool, so the rule is an allowlist, not a blocklist of the tricks we thought of.
|
|
//
|
|
// The backslash is not paranoia: browsers normalise "\" to "/" in a URL, so "/\evil.example"
|
|
// arrives at the parser as "//evil.example" — a protocol-relative URL — while url.Parse here reads
|
|
// it as an ordinary path with a strange name and waves it through.
|
|
func safeReturnTo(raw string) string {
|
|
if raw == "" || raw[0] != '/' {
|
|
return ""
|
|
}
|
|
// Decode once more before judging. The query value has been unescaped exactly once, so "%5c"
|
|
// is still text here while the browser will read the redirect target as a backslash and
|
|
// normalise it to a slash: /%5c/evil.example is /\/evil.example is //evil.example.
|
|
decoded, err := url.PathUnescape(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, s := range [2]string{raw, decoded} {
|
|
if strings.ContainsAny(s, "\\\x00\t\r\n") {
|
|
return ""
|
|
}
|
|
if len(s) > 1 && (s[1] == '/' || s[1] == '\\') {
|
|
return "" // protocol-relative: a host, not a path
|
|
}
|
|
}
|
|
// Parsed for normalisation — u.String() is what reaches a Location header, and it escapes what
|
|
// the raw form left bare. The three emptiness conditions are a backstop, not a layer: nothing
|
|
// starting with "/" can carry a scheme or an opaque part, and a host needs the "//" the check
|
|
// above already refused, so no input reaches them and no test can pin them (PD-47, measured).
|
|
// They stay against a future change in url.Parse; the guarantee itself is pinned by
|
|
// FuzzSafeReturnTo, which resolves the result against the site's base URL.
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme != "" || u.Host != "" || u.Opaque != "" {
|
|
return ""
|
|
}
|
|
return u.String()
|
|
}
|