first commit
This commit is contained in:
374
go-api/internal/httpserver/auth.go
Normal file
374
go-api/internal/httpserver/auth.go
Normal file
@@ -0,0 +1,374 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user