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 | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/auth/handler.go |
| Original SHA-256 | ef11c5426ca1915990a5d1509de8d5e3c231711bbaeed0971f2833d63fdf9aef |
| Git blob SHA | db9124dcc0db18bbb7fee9a22f43f5f602d19b5c |
| Reading-copy SHA-256 | ef11c5426ca1915990a5d1509de8d5e3c231711bbaeed0971f2833d63fdf9aef |
Recovered source transcript
Download complete source text ↓532 lines · 18.8 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)
// SELF SERVICE PROFILE, amendment 6 decision 4, Track U item 2. No capability gate and no role gate:
// editing your OWN name is not an act of authority, and the whole defect was that a trivial personal
// edit had no path and escalated to the top of the ladder. The subject is read from the session inside
// the handler, never from the body, so this route cannot be aimed at another account.
r.Patch("/me/profile", h.updateOwnProfile)
r.With(security.RequireRole(security.RoleAdmin)).Get("/users", h.listUsers)
// TENANT NAME EDITING, amendment 6 decision 5, Track U item 3. Gated on the same capability as a role
// change, because correcting someone else's identity is an administrative act, and additionally bounded
// by outranking inside the service so an admin cannot edit a peer or a superior.
r.With(security.RequireCapability(security.CapUserRoleChange)).Patch("/users/{id}/profile", h.updateUserProfile)
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 401 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 == "" {
writeUnauthenticated(w, "no session")
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)
}
// An EXPIRED session and an ABSENT one are the same answer, deliberately: a distinguishable
// response would tell a prober whether a token they hold was ever real.
writeUnauthenticated(w, "no session")
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 {
writeUnauthenticated(w, "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 {
writeUnauthenticated(w, "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 {
writeUnauthenticated(w, "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 {
writeUnauthenticated(w, "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 {
writeUnauthenticated(w, "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)
}
// updateOwnProfile is the self service identity edit. The subject is the SESSION's user, never a value from
// the request body, so the route cannot be aimed at another account regardless of what is sent.
func (h *Handler) updateOwnProfile(w http.ResponseWriter, r *http.Request) {
p, ok := tenant.UserFromContext(r.Context())
if !ok {
writeUnauthenticated(w, "no session")
return
}
org, ok := tenant.OrgFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "no organization context")
return
}
var in ProfileInput
if !decode(w, r, &in) {
return
}
out, err := h.svc.UpdateOwnProfile(r.Context(), p.UserID, org, in)
if err != nil {
writeProfileError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// updateUserProfile is the administrative identity edit, so a client admin can correct a colleague's name.
// The capability gate is on the route; the outranking bound is enforced in the service, which is what makes
// an API caller subject to the same ceiling as the button.
func (h *Handler) updateUserProfile(w http.ResponseWriter, r *http.Request) {
p, ok := tenant.UserFromContext(r.Context())
if !ok {
writeUnauthenticated(w, "no session")
return
}
org, ok := tenant.OrgFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "no organization context")
return
}
var in ProfileInput
if !decode(w, r, &in) {
return
}
out, err := h.svc.UpdateUserProfile(r.Context(), p.UserID, p.Role, org, chi.URLParam(r, "id"), in)
if err != nil {
writeProfileError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// writeProfileError maps the profile write failures: outranking is 403, a missing target is 404, a bad
// field is 400, and anything else is a 500 without leaking the detail.
func writeProfileError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrOutranked):
writeError(w, http.StatusForbidden, err.Error())
case errors.Is(err, ErrNotFound):
writeError(w, http.StatusNotFound, "user not found")
case errors.Is(err, ErrNoDatabase):
writeError(w, http.StatusServiceUnavailable, "database not configured")
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
// writeUnauthenticated answers a request that presented NO USABLE CREDENTIAL.
//
// 401, NOT 403, and the distinction is not pedantry. 401 means "I do not know who you are, authenticate";
// 403 means "I know who you are and you may not do this". A client cannot tell those apart from a 403, so an
// expired session looked identical to a permission refusal, and the only sensible response to either was to
// guess. This surface answered 403 to both for its whole life; it was deferred while changing it would have
// meant coordinating with a frontend, and it is changed now because that frontend does not exist yet, which
// makes this the cheapest this fix will ever be.
//
// The WWW-Authenticate header is REQUIRED on a 401 by RFC 9110. The scheme is deliberately not Basic or
// Digest: a browser answers either of those by throwing up its own credential dialog over the application,
// which is exactly the experience a cookie session exists to avoid.
func writeUnauthenticated(w http.ResponseWriter, msg string) {
w.Header().Set("WWW-Authenticate", `Cookie realm="missionMATCH"`)
writeError(w, http.StatusUnauthorized, msg)
}