ThatAIguyA world of ideas

THE CONSCIOUSNESS ATLAS
Read. Experiment. Follow the evidence.

ConsciousnessLearn · Explore · Joseph Anady

Janady13/missionmatch / internal/modules/auth/handler.go

Source identity, revision, and recovered contents.

GitHub sourceSource text recovered

Source identity

FieldRecorded value
Source typeGitHub source
Original recordJanady13/missionmatch / internal/modules/auth/handler.go
DateNot specified in source
RepositoryJanady13/missionmatch
Revision720d47fae476b0d38477ae4401697d372b1ea2bc
Branchfeature/opportunity-to-outcome
Path or archive memberinternal/modules/auth/handler.go
Original SHA-256ef11c5426ca1915990a5d1509de8d5e3c231711bbaeed0971f2833d63fdf9aef
Git blob SHAdb9124dcc0db18bbb7fee9a22f43f5f602d19b5c
Reading-copy SHA-256ef11c5426ca1915990a5d1509de8d5e3c231711bbaeed0971f2833d63fdf9aef

Original source ↗

Recovered source transcript

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) }

Search equations, builds, manuscript text, and recovered sources.