Files
krow_backend/go-api/internal/httpserver/auth.go
2026-08-28 12:21:44 +05:30

429 lines
17 KiB
Go

package httpserver
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/orgctx"
)
// The authentication surface: sign in, sign out, and the middleware that turns
// a cookie into an identity.
//
// The shape of the whole thing is one sentence: the browser holds an opaque
// random string it cannot read, the database holds SHA-256 of that string, and
// every protected request is a lookup from one to the other. No claim travels
// in the request. There is no token in a JSON body, no user id in a query
// string, no organization in a header — those are all things a client can
// write, and a client writing its own identity is the bug this replaces.
// sessionCookieName is the cookie the browser holds.
//
// The "__Host-" prefix would be stronger — browsers enforce Secure, Path=/ and
// no Domain on it — but it also *requires* Secure, which cannot be set over
// plain HTTP on localhost. A cookie name that only works in production is worse
// than a plain one that works everywhere, so the hardening is done by the
// attributes below instead, where it can be conditional.
const sessionCookieName = "krow_session"
/* ── Cookie ─────────────────────────────────────────────────────────────── */
// secureCookies reports whether Secure may be set.
//
// Secure means "only ever send this over HTTPS". Setting it in development
// would mean the browser silently declines to send the cookie back to
// http://localhost, and the symptom is an endless loop of successful logins
// that never authenticate anything.
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
// sessionSameSite reports the SameSite mode the session cookie must carry.
//
// Lax is the default and the safer value: it closes the CSRF hole by refusing
// to travel on cross-site subresource requests. That is exactly right when the
// page and the API share an origin, which is the supported deployment.
//
// When the API is configured with a CORS allowlist, the deployment is by
// definition the other one: a page on some other origin calls this API
// directly. A Lax cookie is never sent on those requests, so login would
// succeed once and every request after it would arrive anonymous. None is the
// only mode a browser will send cross-site, and it requires Secure — which is
// why an origin allowlist forces Secure on regardless of AppEnv.
func (s *Server) sessionSameSite() http.SameSite {
// An explicit HTTP_COOKIE_SAMESITE wins, because the derivation below
// cannot see the one thing that decides the answer: whether the frontend
// is on the same SITE as this API.
//
// CORS is about ORIGIN and SameSite is about SITE, and they are not the
// same question. platform.krowforce.com calling mcp.krowforce.com is
// cross-origin — so it needs the CORS allowlist — and same-site, so a Lax
// cookie is sent on its requests anyway. Deriving None from "CORS is
// configured" gives up the only CSRF protection this API has, in exchange
// for nothing that deployment needed.
//
// So the allowlist decides the DEFAULT and an operator decides the value.
// This also closes a trap: config.Load has always parsed and validated
// HTTP_COOKIE_SAMESITE, and nothing read it — a deployment that set it saw
// it silently ignored.
switch s.cfg.HTTP.CookieSameSite {
case "none":
return http.SameSiteNoneMode
case "strict":
return http.SameSiteStrictMode
case "lax":
return http.SameSiteLaxMode
}
// Unset. A configured CORS allowlist means a browser on another origin is
// expected, and None is the only mode that survives a genuinely cross-site
// one. Safe as a default because it is only reached when nobody has said
// otherwise.
if len(s.cfg.HTTP.CORSOrigins) > 0 {
return http.SameSiteNoneMode
}
return http.SameSiteLaxMode
}
// crossSiteCookies reports whether the cookie must be marked Secure because it
// has to travel cross-site. SameSite=None without Secure is rejected outright
// by every current browser.
func (s *Server) crossSiteCookies() bool {
return s.sessionSameSite() == http.SameSiteNoneMode
}
// setSessionCookie writes the raw token to the browser.
//
// This is the only place the raw token is written to a response, and it goes
// into a Set-Cookie header rather than a body: HttpOnly means no script on the
// page can read it, which is what makes an XSS bug stop short of session theft.
//
// maxAge matches the session's own lifetime so the browser drops the cookie at
// roughly the moment the server would refuse it. The server is still the
// authority — a cookie the browser keeps too long is simply rejected — but a
// cookie that expires with its session keeps the two honest.
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
// HttpOnly: script cannot read it.
HttpOnly: true,
// Lax, not Strict and not None. Strict would drop the cookie on any
// cross-site navigation, so following a link into the app would land on
// a login page despite a live session. None would require Secure and
// would send the cookie on cross-site POSTs, which is the CSRF hole Lax
// exists to close.
SameSite: s.sessionSameSite(),
Secure: s.secureCookies() || s.crossSiteCookies(),
MaxAge: int(lifetime.Seconds()),
})
}
// clearSessionCookie expires the cookie in the browser.
//
// The attributes must match the ones it was set with — a cookie is identified
// by name, domain and path, so clearing it with a different Path leaves the
// original in place and the browser keeps sending a token the server has
// already deleted.
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: s.sessionSameSite(),
Secure: s.secureCookies() || s.crossSiteCookies(),
MaxAge: -1,
})
}
// sessionToken reads the raw token out of the request, if there is one.
func sessionToken(r *http.Request) string {
c, err := r.Cookie(sessionCookieName)
if err != nil || c == nil {
return ""
}
return strings.TrimSpace(c.Value)
}
/* ── Routes ─────────────────────────────────────────────────────────────── */
func (s *Server) routeAuth(mux *http.ServeMux) int {
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
return 2
}
// loginRequest is the body of POST /api/v1/auth/login.
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
RememberMe bool `json:"remember_me"`
}
// handleLogin verifies a password and issues a session.
//
// The order of operations is deliberate:
//
// 1. Parse and validate the *shape* of the request. A missing field is a
// malformed request, not a failed login, and saying so reveals nothing.
// 2. Check the rate limit, before any expensive work. Refusing early is the
// point — an attacker must not be able to make the server hash for them.
// 3. Verify the credentials, which takes the same measurable time whether the
// email exists or not (see auth.Credentials).
// 4. Issue the session and set the cookie.
//
// Every failure in step 3 produces one identical response. The reason goes to
// the log, at warn, with the email — which is already in the request — and
// never the password.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeInto(r, &req); err != nil {
writeError(w, s.log, err)
return
}
email := strings.TrimSpace(req.Email)
details := map[string]string{}
if email == "" {
details["email"] = "an email address is required"
}
if req.Password == "" {
details["password"] = "a password is required"
}
if len(details) > 0 {
writeError(w, s.log, domain.Validation("email and password are required", details))
return
}
// Two budgets, both consulted, both counted. The per-email budget stops one
// account being ground down from many addresses; the per-address budget,
// which is wider, stops one host working through many accounts. They are
// separate limiters because they are deliberately different sizes — see the
// note on Server.
addr := clientAddr(r)
emailKey := strings.ToLower(email)
for _, check := range []struct {
limiter *attemptLimiter
key string
scope string
}{
{s.loginByEmail, emailKey, "email"},
{s.loginByAddr, addr, "address"},
} {
if ok, retryAfter := check.limiter.Allow(check.key); !ok {
w.Header().Set("Retry-After", retryAfterSeconds(retryAfter))
s.log.Warn("login rate limited", "scope", check.scope,
"email", email, "addr", addr,
"retry_after_seconds", retryAfterSeconds(retryAfter))
writeError(w, s.log, domain.RateLimited(
"too many sign-in attempts; wait a few minutes and try again"))
return
}
}
user, reason, err := s.credentials.Verify(r.Context(), email, req.Password)
if errors.Is(err, auth.ErrInvalidCredentials) {
s.loginByEmail.Fail(emailKey)
s.loginByAddr.Fail(addr)
// The reason is the whole value of this line and must never leave it.
s.log.Warn("login failed", "email", email, "addr", addr, "reason", string(reason))
writeError(w, s.log, domain.Unauthenticated())
return
}
if err != nil {
// The database is down, or a stored hash is unreadable. The caller's
// credentials were never judged, so this is a 500 and not a 401.
writeError(w, s.log, domain.Internal(err))
return
}
token, sess, err := s.sessions.Issue(r.Context(), user.ID, req.RememberMe)
if err != nil {
writeError(w, s.log, domain.Internal(err))
return
}
// A correct password clears the email's penalty, so two typos followed by a
// success leave nothing behind. The address counter is left alone: one
// correct login should not wipe the budget for every other account being
// tried from the same host.
s.loginByEmail.Reset(emailKey)
s.setSessionCookie(w, token, time.Until(sess.ExpiresAt))
// Best effort, deliberately after the session exists: a failure to stamp
// last_login_at is a lost diagnostic, not a reason to refuse a sign-in that
// has already succeeded.
if err := s.users.MarkLoggedIn(r.Context(), user.ID, s.now()); err != nil {
s.log.Warn("could not record last_login_at", "user_id", user.ID, "error", err)
}
s.log.Info("login", "user_id", user.ID, "email", user.Email,
"remember_me", req.RememberMe, "session_id", sess.ID,
"expires_at", sess.ExpiresAt, "absolute_expires_at", sess.AbsoluteExpiresAt)
// The body is the user, in exactly the shape GET /me returns, so the
// frontend can render the signed-in state without a second round trip.
//
// The token is NOT here and must never be. It went out in a Set-Cookie
// header the page cannot read; putting it in the body would hand it to
// every script on the page and undo HttpOnly entirely.
record, err := s.userRecord(r.Context(), s.db.Pool, user.ID)
if err != nil {
writeError(w, s.log, err)
return
}
writeRecord(w, http.StatusOK, record)
}
// handleLogout revokes the session behind the cookie and clears the cookie.
//
// Idempotent by construction: no cookie, an unknown token and a live session
// all end the same way — the cookie is cleared and the answer is 200. Logging
// out is a request to not be signed in, and the caller is not signed in
// afterwards in every one of those cases.
//
// It is deliberately public. Requiring a valid session to log out means a user
// whose session has already expired gets a 401 from the one action that would
// have tidied up their stale cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if token := sessionToken(r); token != "" {
if err := s.sessions.Revoke(r.Context(), token); err != nil {
// Revoke already treats "no such session" as success, so this is a
// real failure — the database, most likely. Clearing the cookie is
// still the right thing to do, and reporting a 500 for a logout
// would leave the caller signed in with no way to fix it.
s.log.Error("could not revoke session on logout", "error", err)
}
}
s.clearSessionCookie(w)
writeJSON(w, http.StatusOK, envelope{Data: map[string]any{"status": "signed_out"}})
}
/* ── Middleware ─────────────────────────────────────────────────────────── */
// publicPaths are the only endpoints reachable without a session.
//
// An allowlist rather than a list of protected prefixes, so the failure mode of
// forgetting to update it is a route that refuses everyone — not one that
// serves everyone. A new endpoint is private until someone deliberately says
// otherwise, which is the direction a mistake should fall in.
var publicPaths = map[string]bool{
"/health": true,
"/api/v1/auth/login": true,
"/api/v1/auth/logout": true,
}
// authenticate resolves the session cookie into an identity, or refuses.
//
// This replaces devOrgMiddleware, which put a fixed organization on every
// request with no credential behind it. The seam is the same one that comment
// promised: everything downstream still reads the organization from
// orgctx, and not one service or repository changed.
//
// What the request cannot influence: nothing here reads the body, the query
// string or any header other than Cookie. The user id, the organization and the
// role are all read from the sessions and users tables, keyed by a token the
// client cannot forge without already holding it.
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if publicPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
token := sessionToken(r)
if token == "" {
writeError(w, s.log, domain.Unauthenticated())
return
}
sess, err := s.sessions.Authenticate(r.Context(), token)
if err != nil {
// Not found and expired are logged apart and answered identically.
// Clearing the cookie stops the browser re-sending a token that
// will never work again.
s.log.Debug("session rejected", "reason", sessionRejection(err), "path", r.URL.Path)
if errors.Is(err, auth.ErrSessionNotFound) || errors.Is(err, auth.ErrSessionExpired) ||
errors.Is(err, auth.ErrEmptyToken) {
s.clearSessionCookie(w)
writeError(w, s.log, domain.Unauthenticated())
return
}
writeError(w, s.log, domain.Internal(err))
return
}
// The user is re-read on every request rather than cached in the
// session row, so suspending an account takes effect on the account's
// next request instead of whenever its session happens to lapse.
user, err := s.users.FindByID(r.Context(), sess.UserID)
if err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
// The FK cascades, so this should be unreachable. If it happens
// the session is orphaned and worth destroying.
s.log.Warn("session references a missing user", "session_id", sess.ID)
_ = s.sessions.RevokeID(r.Context(), sess.ID)
s.clearSessionCookie(w)
writeError(w, s.log, domain.Unauthenticated())
return
}
writeError(w, s.log, domain.Internal(err))
return
}
if !user.IsActive() {
// Suspension revokes on contact. Leaving the session alive would
// mean a suspended account keeps a working cookie for up to thirty
// days, refused one request at a time.
s.log.Warn("session for an inactive user revoked",
"user_id", user.ID, "status", user.Status)
_ = s.sessions.RevokeID(r.Context(), sess.ID)
s.clearSessionCookie(w)
writeError(w, s.log, domain.Unauthenticated())
return
}
id := authctx.Identity{
UserID: user.ID, OrgID: user.OrgID, Email: user.Email,
FullName: user.FullName, Role: user.Role, AccountType: user.AccountType,
Status: user.Status, SessionID: sess.ID, ExpiresAt: sess.ExpiresAt,
}
ctx := authctx.With(r.Context(), id)
// The organization comes from the user's row, never from the request.
// Every service and repository already takes it as a parameter, so this
// one line is the whole of the tenancy change.
ctx = orgctx.With(ctx, user.OrgID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionRejection names why a session was refused, for the log only.
func sessionRejection(err error) string {
switch {
case errors.Is(err, auth.ErrSessionNotFound):
return "not_found"
case errors.Is(err, auth.ErrSessionExpired):
return "expired"
case errors.Is(err, auth.ErrEmptyToken):
return "empty_token"
default:
return "error"
}
}
// retryAfterSeconds renders a duration for the Retry-After header, rounded up
// and never below one second — "Retry-After: 0" invites an immediate retry.
func retryAfterSeconds(d time.Duration) string {
secs := int(d.Round(time.Second) / time.Second)
if secs < 1 {
secs = 1
}
return strconv.Itoa(secs)
}