- TOTP second factor (RFC 6238, secret encrypted under the master key), ten hashed single-use recovery codes, two-step login with a pending token bound to the client address, account enrolment/disable, admin reset - tests/security: prompt-injection corpus never acts or creates evidence-free beliefs; roles and CSRF on form and API paths; cross-mind isolation (demo scenario 5); stored-XSS escaping and CSP across pages; secrets never echoed (API, pages, database, archives); hostile archive import; session lifecycle and security headers - OpenAPI 3.1 document generated from the route table, served at /api/v1/openapi.json and kept complete by a test - gosec clean (justified #nosec annotations, cookie/redirect hardening, bounded multipart parsing, UUID packing without narrowing conversions); govulncheck clean with toolchain pinned to go1.26.8 - THREAT_MODEL verification pointers, SECURITY.md testing section, README, CHANGELOG 0.1.0, docs/RELEASE_NOTES.md, PLAN status
137 lines
4.3 KiB
Go
137 lines
4.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// CookieName is the session cookie.
|
|
const CookieName = "noema_session"
|
|
|
|
// CSRFHeader carries the CSRF token for fetch/XHR requests; forms use the
|
|
// hidden field named CSRFField.
|
|
const (
|
|
CSRFHeader = "X-CSRF-Token"
|
|
CSRFField = "_csrf"
|
|
)
|
|
|
|
type principalKey struct{}
|
|
|
|
// PrincipalFrom returns the authenticated principal, if any.
|
|
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
|
p, ok := ctx.Value(principalKey{}).(Principal)
|
|
return p, ok
|
|
}
|
|
|
|
// WithPrincipal is exported for tests and internal callers that construct contexts.
|
|
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
|
return context.WithValue(ctx, principalKey{}, p)
|
|
}
|
|
|
|
// Middleware resolves the session cookie or bearer token into a Principal and
|
|
// enforces CSRF for cookie sessions on unsafe methods. Unauthenticated requests
|
|
// pass through without a principal; route guards decide what to do.
|
|
func (s *Service) Middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var p Principal
|
|
var authed bool
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer noema_") {
|
|
if pp, err := s.AuthenticateToken(r.Context(), strings.TrimPrefix(h, "Bearer noema_")); err == nil {
|
|
p, authed = pp, true
|
|
}
|
|
} else if c, err := r.Cookie(CookieName); err == nil && c.Value != "" {
|
|
if pp, err := s.Authenticate(r.Context(), c.Value); err == nil {
|
|
p, authed = pp, true
|
|
}
|
|
}
|
|
if authed && p.ViaCookie && !safeMethod(r.Method) {
|
|
if !s.csrfOK(r, p) {
|
|
http.Error(w, "CSRF token missing or invalid", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
if authed {
|
|
r = r.WithContext(WithPrincipal(r.Context(), p))
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func safeMethod(m string) bool {
|
|
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
|
|
}
|
|
|
|
func (s *Service) csrfOK(r *http.Request, p Principal) bool {
|
|
// Origin/Referer must match the host when present (defence in depth).
|
|
if o := r.Header.Get("Origin"); o != "" {
|
|
if u, err := url.Parse(o); err != nil || !strings.EqualFold(u.Host, r.Host) {
|
|
return false
|
|
}
|
|
} else if ref := r.Header.Get("Referer"); ref != "" {
|
|
if u, err := url.Parse(ref); err != nil || !strings.EqualFold(u.Host, r.Host) {
|
|
return false
|
|
}
|
|
}
|
|
want, err := s.CSRFToken(r.Context(), p.SessionID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
got := r.Header.Get(CSRFHeader)
|
|
if got == "" {
|
|
if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "application/x-www-form-urlencoded") || strings.HasPrefix(ct, "multipart/form-data") {
|
|
got = r.PostFormValue(CSRFField)
|
|
}
|
|
}
|
|
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
|
}
|
|
|
|
// RequireRole returns middleware that rejects requests below min. onFail is
|
|
// invoked for unauthenticated or under-privileged requests so the UI can
|
|
// redirect to login while the API returns JSON.
|
|
func RequireRole(min Role, onFail func(w http.ResponseWriter, r *http.Request, authenticated bool)) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := PrincipalFrom(r.Context())
|
|
if !ok {
|
|
onFail(w, r, false)
|
|
return
|
|
}
|
|
if !p.User.Role.AtLeast(min) {
|
|
onFail(w, r, true)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ClientIP extracts the peer address. Proxy headers are deliberately ignored
|
|
// unless the daemon is told it sits behind a trusted proxy; secure default.
|
|
func ClientIP(r *http.Request, trustProxy bool) string {
|
|
if trustProxy {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
|
}
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
// SetSessionCookie writes the session cookie with secure attributes.
|
|
func SetSessionCookie(w http.ResponseWriter, token string, secure bool, maxAge int) {
|
|
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure, // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
|
SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
|
|
}
|
|
|
|
// ClearSessionCookie expires the session cookie.
|
|
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
|
|
SetSessionCookie(w, "", secure, -1)
|
|
}
|