508 lines
21 KiB
Go
508 lines
21 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 := s.trust.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,
|
|
|
|
// ── The OAuth surface for MCP clients ──────────────────────────────────
|
|
//
|
|
// Four paths, each public for a specific reason rather than because
|
|
// "/oauth/*" is convenient. The namespace is deliberately NOT wildcarded:
|
|
// /oauth/authorize is not here, because it renders a consent screen for a
|
|
// signed-in person and must keep requiring a session.
|
|
//
|
|
// These routes are registered only when OAUTH_ISSUER and MCP_RESOURCE are
|
|
// configured. Listing them here is harmless otherwise — an unregistered
|
|
// path still 404s, it simply does so without being asked for a cookie.
|
|
|
|
// RFC 9728 and RFC 8414. A client with no token cannot read a document
|
|
// that requires one, and these are how it discovers where to get a token.
|
|
// They contain public endpoint URLs and nothing else.
|
|
"/.well-known/oauth-protected-resource": true,
|
|
"/.well-known/oauth-authorization-server": true,
|
|
|
|
// RFC 7591. A client that has never registered has no credential to
|
|
// present; that is what dynamic registration is for.
|
|
"/oauth/register": true,
|
|
|
|
// The client authenticates here with an authorization code or a refresh
|
|
// token in the BODY. This is a back-channel call from the MCP client's own
|
|
// servers — there is no browser and no cookie to send.
|
|
"/oauth/token": true,
|
|
|
|
// Revocation authenticates by presenting the token being revoked, for the
|
|
// same back-channel reason.
|
|
"/oauth/revoke": true,
|
|
|
|
// /mcp is listed here, and it is the entry that most deserves explaining,
|
|
// because "public" is the opposite of what it means for this path.
|
|
//
|
|
// The MCP endpoint authenticates its OWN callers, from the Authorization
|
|
// header, inside mcpserver — every method but the handshake requires a
|
|
// valid bearer token, and the transport ignores whatever identity this
|
|
// middleware may have put in the context. So listing it here does not make
|
|
// it reachable without a credential; it makes THIS middleware step aside
|
|
// so the one that knows how to answer can.
|
|
//
|
|
// It has to step aside. An MCP client discovers how to authenticate by
|
|
// calling the endpoint with no token and reading the WWW-Authenticate
|
|
// header of the 401 — RFC 9728, and the first step of the whole flow.
|
|
// This middleware's 401 carries no such header, so guarding /mcp here
|
|
// would mean a client received a refusal with nowhere to go and the
|
|
// connection could never be established. That is not a hypothetical: it is
|
|
// what TestMCPWithoutBearerReturns401AndDiscoveryPointer caught.
|
|
//
|
|
// What stops a cookie authenticating an MCP call is therefore NOT this
|
|
// allowlist — it is mcpserver taking its identity as a parameter rather
|
|
// than from the request context. See mcpserver/auth.go, and
|
|
// TestMCPRejectsACookieSession below.
|
|
"/mcp": true,
|
|
|
|
// /oauth/authorize is here for the same reason as /mcp, and it took a live
|
|
// client to show why.
|
|
//
|
|
// It was withheld on the reasoning that consent needs a signed-in person,
|
|
// so the route "genuinely wants the cookie". That reasoning was right about
|
|
// the requirement and wrong about who enforces it. THE HANDLER already
|
|
// enforces it — authserver.go asks sessions.CurrentUser, refuses to render
|
|
// consent without an identity, and redirects an anonymous visitor to the
|
|
// login with the authorization request preserved in returnTo. Guarding the
|
|
// path HERE meant that handler was never reached, so the redirect it
|
|
// performs could never run: every signed-out visitor got this middleware's
|
|
// JSON 401 instead of a login page.
|
|
//
|
|
// That is not a cosmetic difference. A first-time connector user is signed
|
|
// out by definition, so OAuth's browser leg was unreachable for exactly the
|
|
// people who needed it. Claude Web stopped here — discovery, registration,
|
|
// then a 401 with nowhere to go. Claude Desktop only got past it because a
|
|
// session had been established by hand beforehand.
|
|
//
|
|
// Listing it grants nothing: no session still means no consent screen and
|
|
// no authorization code, and the consent POST still requires the
|
|
// session-bound CSRF token. What changes is only WHICH layer says no, and
|
|
// therefore whether it can say "sign in here" instead of "no".
|
|
"/oauth/authorize": 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)
|
|
}
|