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" } // 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: http.SameSiteLaxMode, Secure: s.secureCookies(), 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: http.SameSiteLaxMode, Secure: s.secureCookies(), 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) }