Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/auth/handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/modules/auth/handler.go |
| Original SHA-256 | d5a35500ba1d679d2a606f287341dad7315e678c2fa0ecef0f54f90c6a4b8b39 |
| Git blob SHA | bf19774ea7c5baf74ce0527dea9c3d681f7cd801 |
| Reading-copy SHA-256 | d5a35500ba1d679d2a606f287341dad7315e678c2fa0ecef0f54f90c6a4b8b39 |
Recovered source transcript
Download complete source text ↓437 lines · 14.6 KB
package auth
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// Handler is the HTTP surface for auth. It owns cookie issuance and the public/tenant route split.
type Handler struct {
svc *Service
csrf *security.CSRF
cookieSecure bool
allowedOrigins []string
trustedProxyHeader string
}
// NewHandler builds the auth handler. trustedProxyHeader names the header carrying the real client IP
// when the app sits behind a trusted reverse proxy (the app must not be directly exposed); empty uses
// the TCP peer address.
func NewHandler(svc *Service, csrf *security.CSRF, cookieSecure bool, allowedOrigins []string, trustedProxyHeader string) *Handler {
return &Handler{svc: svc, csrf: csrf, cookieSecure: cookieSecure, allowedOrigins: allowedOrigins, trustedProxyHeader: trustedProxyHeader}
}
// SessionCookieName and CSRFCookieName return the cookie names that match the Secure flag, so the
// __Host- prefix is only used when Secure is set (the prefix requires it).
func (h *Handler) SessionCookieName() string {
if h.cookieSecure {
return SessionCookie
}
return sessionCookieInsecure
}
// CSRFCookieName returns the CSRF cookie name; the CSRF middleware must be built with the same value.
func (h *Handler) CSRFCookieName() string {
if h.cookieSecure {
return CSRFCookie
}
return csrfCookieInsecure
}
// PublicRoutes mounts the pre tenant endpoints. They run OUTSIDE the session middleware (they operate
// on a pre MFA session or no session) but the caller wraps them in the CSRF Origin allowlist. The
// login, mfa, and accept handlers read the session cookie directly where they need a pending session.
func (h *Handler) PublicRoutes(r chi.Router) {
r.Get("/auth/csrf", h.issueCSRF)
r.Post("/auth/login", h.login)
r.Post("/auth/mfa/enroll", h.mfaEnroll)
r.Post("/auth/mfa/activate", h.mfaActivate)
r.Post("/auth/mfa/verify", h.mfaVerify)
r.Post("/auth/mfa/recover", h.mfaRecover)
r.Post("/auth/invite/accept", h.inviteAccept)
r.Post("/auth/logout", h.logout)
}
// TenantRoutes mounts the authenticated endpoints. The caller mounts these INSIDE the session
// middleware and the CSRF middleware; per route capabilities are applied here.
func (h *Handler) TenantRoutes(r chi.Router) {
r.Get("/auth/session", h.session)
r.Post("/auth/logout-all", h.logoutAll)
r.Post("/auth/password/change", h.passwordChange)
r.With(security.RequireRole(security.RoleAdmin)).Get("/users", h.listUsers)
r.With(security.RequireCapability(security.CapUserInvite)).Post("/users/invite", h.invite)
r.With(security.RequireCapability(security.CapUserRoleChange)).Patch("/users/{id}/role", h.changeRole)
// Tool entitlement is gated on the same capability as a role change: both are access control writes,
// and an actor able to change what someone may DO but not what they may SEE would be a split that only
// confuses. The service applies the extra bound that an actor may grant only tools they hold.
r.With(security.RequireCapability(security.CapUserRoleChange)).Patch("/users/{id}/tools", h.changeTools)
r.With(security.RequireCapability(security.CapUserDisable)).Patch("/users/{id}/status", h.changeStatus)
r.With(security.RequireCapability(security.CapUserDisable)).Post("/users/{id}/mfa/reset", h.resetMFA)
r.With(security.RequireCapability(security.CapUserInvite)).Post("/users/{id}/recovery", h.reissueRecovery)
}
// SessionMiddleware resolves a validated active session into tenant context, replacing the dev header
// resolver. It reads the __Host- session cookie, requires an active MFA satisfied session, and stashes
// the authoritative org and the principal. Any failure is 403 before a handler runs (fail closed).
func (h *Handler) SessionMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(h.SessionCookieName())
if err != nil || cookie.Value == "" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
principal, org, verr := h.svc.Validate(r.Context(), cookie.Value)
if verr != nil {
// The request is denied regardless, but the rejection audit must not vanish silently.
if aerr := h.svc.auditSessionRejected(r.Context(), h.clientIP(r), r.UserAgent()); aerr != nil {
slog.Error("auth: session rejection audit write failed", "error", aerr)
}
http.Error(w, "forbidden", http.StatusForbidden)
return
}
ctx := tenant.WithUser(tenant.WithOrg(r.Context(), org), principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// ---- public handlers ----
// issueCSRF mints a token bound to whatever session the caller currently holds. It stays public
// because the login form needs a token before any session exists; the binding is what keeps that from
// being a way to hand an attacker a token that works against a victim's session. A caller with no
// session cookie gets the anonymous binding, which is only good for the pre login endpoints, and the
// SPA re mints after login and after each session rotation on the single 403 it already retries.
func (h *Handler) issueCSRF(w http.ResponseWriter, r *http.Request) {
session := ""
if c, err := r.Cookie(h.SessionCookieName()); err == nil {
session = c.Value
}
token, err := h.csrf.Issue(session)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
h.setCSRFCookie(w, token)
writeJSON(w, http.StatusOK, map[string]string{"csrf_token": token})
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
var in LoginInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.Login(r.Context(), in, h.clientIP(r), r.UserAgent())
if err != nil {
writeAuthError(w, err)
return
}
h.setSessionCookie(w, res.Token)
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) mfaEnroll(w http.ResponseWriter, r *http.Request) {
token, ok := h.sessionToken(r)
if !ok {
writeError(w, http.StatusForbidden, "no session")
return
}
res, err := h.svc.EnrollMFA(r.Context(), token)
if err != nil {
writeAuthError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) mfaActivate(w http.ResponseWriter, r *http.Request) {
token, ok := h.sessionToken(r)
if !ok {
writeError(w, http.StatusForbidden, "no session")
return
}
var in CodeInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.ActivateMFA(r.Context(), token, in, h.clientIP(r), r.UserAgent())
if err != nil {
writeAuthError(w, err)
return
}
h.setSessionCookie(w, res.Token)
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) mfaVerify(w http.ResponseWriter, r *http.Request) {
token, ok := h.sessionToken(r)
if !ok {
writeError(w, http.StatusForbidden, "no session")
return
}
var in CodeInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.VerifyTOTP(r.Context(), token, in, h.clientIP(r), r.UserAgent())
if err != nil {
writeAuthError(w, err)
return
}
h.setSessionCookie(w, res.Token)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (h *Handler) mfaRecover(w http.ResponseWriter, r *http.Request) {
token, ok := h.sessionToken(r)
if !ok {
writeError(w, http.StatusForbidden, "no session")
return
}
var in CodeInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.RecoverMFA(r.Context(), token, in, h.clientIP(r), r.UserAgent())
if err != nil {
writeAuthError(w, err)
return
}
h.setSessionCookie(w, res.Token)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (h *Handler) inviteAccept(w http.ResponseWriter, r *http.Request) {
var in AcceptInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.AcceptInvite(r.Context(), in, h.clientIP(r), r.UserAgent())
if err != nil {
writeAuthError(w, err)
return
}
h.setSessionCookie(w, res.Token)
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
if token, ok := h.sessionToken(r); ok {
_ = h.svc.Logout(r.Context(), token, h.clientIP(r), r.UserAgent())
}
h.clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
// ---- tenant handlers ----
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
info, err := h.svc.SessionInfo(r.Context())
writeOrError(w, info, err)
}
func (h *Handler) logoutAll(w http.ResponseWriter, r *http.Request) {
token, _ := h.sessionToken(r)
if err := h.svc.LogoutAll(r.Context(), token); err != nil {
writeAuthError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) passwordChange(w http.ResponseWriter, r *http.Request) {
token, ok := h.sessionToken(r)
if !ok {
writeError(w, http.StatusForbidden, "no session")
return
}
var in ChangePasswordInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.ChangePassword(r.Context(), token, in, h.clientIP(r), r.UserAgent())
if err != nil {
writeAuthError(w, err)
return
}
h.setSessionCookie(w, res.Token)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.svc.ListUsers(r.Context())
writeOrError(w, users, err)
}
func (h *Handler) invite(w http.ResponseWriter, r *http.Request) {
var in InviteInput
if !decode(w, r, &in) {
return
}
res, err := h.svc.Invite(r.Context(), in, h.clientIP(r), r.UserAgent())
writeOrError(w, res, err)
}
func (h *Handler) changeRole(w http.ResponseWriter, r *http.Request) {
var in RoleChangeInput
if !decode(w, r, &in) {
return
}
err := h.svc.ChangeRole(r.Context(), chi.URLParam(r, "id"), in, h.clientIP(r), r.UserAgent())
writeStatusOrError(w, err)
}
func (h *Handler) changeStatus(w http.ResponseWriter, r *http.Request) {
var in StatusChangeInput
if !decode(w, r, &in) {
return
}
err := h.svc.ChangeStatus(r.Context(), chi.URLParam(r, "id"), in, h.clientIP(r), r.UserAgent())
writeStatusOrError(w, err)
}
func (h *Handler) resetMFA(w http.ResponseWriter, r *http.Request) {
err := h.svc.ResetMFA(r.Context(), chi.URLParam(r, "id"), h.clientIP(r), r.UserAgent())
writeStatusOrError(w, err)
}
func (h *Handler) reissueRecovery(w http.ResponseWriter, r *http.Request) {
res, err := h.svc.ReissueRecovery(r.Context(), chi.URLParam(r, "id"), h.clientIP(r), r.UserAgent())
writeOrError(w, res, err)
}
// ---- cookies ----
func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: h.SessionCookieName(), Value: token, Path: "/", HttpOnly: true,
Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
})
}
func (h *Handler) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: h.SessionCookieName(), Value: "", Path: "/", HttpOnly: true,
Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1,
})
}
// setCSRFCookie sets the companion CSRF cookie. It is NOT HttpOnly because the SPA reads it to echo
// the token into the X-CSRF-Token header (the signed double submit pattern).
func (h *Handler) setCSRFCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: h.CSRFCookieName(), Value: token, Path: "/", HttpOnly: false,
Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
})
}
// ---- helpers ----
func (h *Handler) sessionToken(r *http.Request) (string, bool) {
c, err := r.Cookie(h.SessionCookieName())
if err != nil || c.Value == "" {
return "", false
}
return c.Value, true
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return false
}
return true
}
func writeOrError(w http.ResponseWriter, v any, err error) {
if err != nil {
writeAuthError(w, err)
return
}
writeJSON(w, http.StatusOK, v)
}
func writeStatusOrError(w http.ResponseWriter, err error) {
if err != nil {
writeAuthError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// writeAuthError maps service errors to status codes. Credential and session failures are 401, never
// leaking which factor failed; authorization failures are 403; missing tenant is 403; no database is
// 503; bad input is 400.
func writeAuthError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrInvalidSession), errors.Is(err, ErrInvalidState):
writeError(w, http.StatusUnauthorized, "unauthorized")
case errors.Is(err, ErrAccountLocked):
writeError(w, http.StatusTooManyRequests, "account temporarily locked")
case errors.Is(err, ErrForbidden):
writeError(w, http.StatusForbidden, "forbidden")
case errors.Is(err, tenant.ErrNoTenant):
writeError(w, http.StatusForbidden, "no validated tenant")
case errors.Is(err, ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, ErrEmailInUse):
writeError(w, http.StatusConflict, "email already in use")
case errors.Is(err, security.ErrPasswordTooLong), errors.Is(err, ErrBadRequest):
writeError(w, http.StatusBadRequest, "bad request")
case errors.Is(err, ErrNoDatabase):
writeError(w, http.StatusServiceUnavailable, "database not configured")
default:
writeError(w, http.StatusInternalServerError, "internal error")
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// clientIP returns the caller's IP for throttling and the audit trail. When trustedProxyHeader is set
// (the app is behind a trusted reverse proxy that overwrites it, and is not directly exposed) it reads
// that header, otherwise it uses the unspoofable TCP peer address. It does NOT trust the leftmost
// X-Forwarded-For (the spoofable value chi RealIP used).
func (h *Handler) clientIP(r *http.Request) string {
if h.trustedProxyHeader != "" {
if v := strings.TrimSpace(r.Header.Get(h.trustedProxyHeader)); v != "" {
return v
}
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i >= 0 && !strings.Contains(host[i:], "]") {
host = host[:i]
}
return strings.Trim(host, "[]")
}
// changeTools replaces a user's tool entitlement with the set in the body.
func (h *Handler) changeTools(w http.ResponseWriter, r *http.Request) {
var in ToolsChangeInput
if !decode(w, r, &in) {
return
}
err := h.svc.SetUserTools(r.Context(), chi.URLParam(r, "id"), in.Tools, h.clientIP(r), r.UserAgent())
writeStatusOrError(w, err)
}