package httpx import ( "context" "errors" "net/http" "time" "bountyboard/internal/domain" "bountyboard/internal/store" ) const ( sessionCookie = "bb_session" csrfCookie = "bb_csrf" csrfHeader = "X-CSRF-Token" // sessions slide forward at most once per hour to avoid a write per // request refreshInterval = time.Hour ) const ( ctxKeyUser ctxKey = 100 + iota ctxKeySession ) // CurrentUser returns the authenticated user, or nil outside requireAuth. func CurrentUser(ctx context.Context) *domain.User { u, _ := ctx.Value(ctxKeyUser).(*domain.User) return u } // CurrentSession returns the active session, or nil outside requireAuth. func CurrentSession(ctx context.Context) *domain.Session { s, _ := ctx.Value(ctxKeySession).(*domain.Session) return s } func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken, csrfToken string) { secure := s.cfg.CookieSecure() http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: sessionToken, Path: "/", MaxAge: int(domain.SessionTTL.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) // Readable by JS so the frontend can mirror it into X-CSRF-Token. http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: csrfToken, Path: "/", MaxAge: int(domain.SessionTTL.Seconds()), HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode, }) } func (s *Server) clearAuthCookies(w http.ResponseWriter) { secure := s.cfg.CookieSecure() http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode, }) } // mustChangeAllowed are the only API paths reachable while a forced password // change is pending. var mustChangeAllowed = map[string]bool{ "/api/v1/auth/me": true, "/api/v1/auth/logout": true, "/api/v1/auth/logout-all": true, "/api/v1/auth/change-password": true, } // requireAuth resolves the session cookie into a user, enforces the disabled // flag and forced password changes, slides session expiry, and stores both // in the request context. func (s *Server) requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { writeError(w, http.StatusUnauthorized, "unauthenticated", "authentication required") return } sess, err := s.store.SessionByToken(r.Context(), c.Value) if err != nil { if errors.Is(err, store.ErrNotFound) { s.clearAuthCookies(w) writeError(w, http.StatusUnauthorized, "unauthenticated", "session expired") return } s.internalError(w, r, "load session", err) return } user, err := s.store.UserByID(r.Context(), sess.UserID) if err != nil { s.clearAuthCookies(w) writeError(w, http.StatusUnauthorized, "unauthenticated", "account no longer exists") return } if user.Disabled { _ = s.store.DeleteSession(r.Context(), sess.Token) s.clearAuthCookies(w) writeError(w, http.StatusForbidden, "account_disabled", "account is disabled") return } if user.MustChangePassword() && !mustChangeAllowed[r.URL.Path] { writeError(w, http.StatusForbidden, "password_change_required", "password change required before continuing") return } if time.Since(sess.RefreshedAt) > refreshInterval { if err := s.store.RefreshSession(r.Context(), sess.Token); err != nil { s.log.Warn("refresh session", "err", err) } } ctx := context.WithValue(r.Context(), ctxKeyUser, user) ctx = context.WithValue(ctx, ctxKeySession, sess) next.ServeHTTP(w, r.WithContext(ctx)) }) } // requireCSRF enforces the double-submit check on mutating methods: the // non-httpOnly cookie value must be echoed in the X-CSRF-Token header. func (s *Server) requireCSRF(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: next.ServeHTTP(w, r) return } c, err := r.Cookie(csrfCookie) if err != nil || c.Value == "" || r.Header.Get(csrfHeader) != c.Value { writeError(w, http.StatusForbidden, "csrf", "missing or mismatched CSRF token") return } next.ServeHTTP(w, r) }) } // requireRole gates a handler on a role flag; must run inside requireAuth. func (s *Server) requireRole(role string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := CurrentUser(r.Context()) if u == nil || !u.Roles.Has(role) { writeError(w, http.StatusForbidden, "forbidden", "requires "+role+" role") return } next.ServeHTTP(w, r) }) } // authed wires the standard chain for an authenticated JSON endpoint. func (s *Server) authed(h http.HandlerFunc) http.Handler { return s.requireAuth(s.requireCSRF(h)) } // authedRole wires authentication + CSRF + a role gate. func (s *Server) authedRole(role string, h http.HandlerFunc) http.Handler { return s.requireAuth(s.requireCSRF(s.requireRole(role, h))) } func (s *Server) internalError(w http.ResponseWriter, r *http.Request, what string, err error) { s.log.Error(what, "err", err, "requestId", RequestID(r.Context()), "path", r.URL.Path) writeError(w, http.StatusInternalServerError, "internal", "internal server error") }