package webapp import ( "context" "crypto/sha256" "crypto/subtle" "net/http" "strings" "time" ) // requireSession is the middleware chain for authenticated pages. // On success, the request context carries the *Session so handlers // can look up the user without re-querying the store. On failure, // the operator is redirected to /web/login. // // When basic auth is configured (WORKER_LOGIN/WORKER_PASSWORD), the // middleware accepts HTTP Basic credentials on /web/api/* as an // alternative to the session cookie. The synthetic session is created // in-memory only (no row in webapp_sessions) so curl -u operators // don't accumulate dead rows. // // First-run password-change enforcement is intentionally OFF: the // requires_change flag is still recorded on webapp_users and the // /web/change-password page is still reachable, but the operator is // not redirected there on first login. The flag is reserved for a // future "hardening" toggle that operators will opt into (e.g. via // a config knob or a UI setting). For now the worker ships with // frictionless first-login so basic auth + standalone bcrypt users // can both reach the operator console immediately. func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Fast-path: HTTP Basic for API consumers when basic auth // is configured. Only the /web/api/* prefix is exposed this // way so the browser-driven login flow stays intact. if s.basicAuthOK && strings.HasPrefix(r.URL.Path, "/web/api/") { if s.checkBasicAuth(r) { next(w, r) return } w.Header().Set("WWW-Authenticate", basicAuthRealm) http.Error(w, "unauthorized", http.StatusUnauthorized) return } sess, err := s.resolveSession(r) if err != nil { redirectTo(w, r, pathLogin) return } ctx := context.WithValue(r.Context(), ctxKeySession, sess) next(w, r.WithContext(ctx)) } } // checkBasicAuth validates the Authorization: Basic header against // the configured WORKER_LOGIN / WORKER_PASSWORD pair. Login is // compared in constant time; password is hashed at startup so the // raw value never enters the request hot path. func (s *Server) checkBasicAuth(r *http.Request) bool { if !s.basicAuthOK { return false } user, pass, ok := r.BasicAuth() if !ok { return false } userHash := sha256.Sum256([]byte(user)) wantUserHash := sha256.Sum256([]byte(s.cfg.BasicAuthLogin)) if subtle.ConstantTimeCompare(userHash[:], wantUserHash[:]) != 1 { return false } passHash := sha256.Sum256([]byte(pass)) return subtle.ConstantTimeCompare(passHash[:], s.basicAuthHash[:]) == 1 } // resolveSession reads the session cookie, validates the row, slides // the absolute expiry forward on activity, and returns the Session. // Returns errMissingSession if any step fails. func (s *Server) resolveSession(r *http.Request) (*Session, error) { c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { return nil, errMissingSession } sess, err := s.store.GetSession(r.Context(), c.Value) if err != nil { return nil, errMissingSession } if err := s.store.TouchSession(r.Context(), sess.ID, s.cfg.SessionIdle, s.cfg.SessionAbs); err != nil { return nil, errMissingSession } sess.LastSeenAt = time.Now().UTC() return sess, nil } // requireCSRF verifies the double-submit token on state-changing // requests. The middleware compares the form/header value against // the session's stored CSRF token. The cookie value is not the // authoritative source on its own (it is set without HttpOnly so // that JS on the same origin could read it, but Phase 1 ships no // JS, so a missing cookie is fine). func (s *Server) requireCSRF(sess *Session, r *http.Request) bool { if sess == nil { return false } if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { return true } want := sess.CSRFToken if want == "" { return false } // Form value is the primary check; header is a fallback for // fetch-style callers. if err := r.ParseForm(); err != nil { return false } got := strings.TrimSpace(r.FormValue("csrf_token")) if got == "" { got = strings.TrimSpace(r.Header.Get("X-CSRF-Token")) } if got == "" || constantTimeEq(got, want) != 1 { return false } return true } // writeSessionCookie sets the session and CSRF cookies. The Secure // flag is set when the request did not arrive over loopback (i.e., // when the operator has gone out of their way to expose the // webapp over a non-loopback interface). func (s *Server) writeSessionCookie(w http.ResponseWriter, r *http.Request, sess *Session) { if sess == nil { return } secure := !isLoopbackRequest(r) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sess.ID, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, Expires: sess.ExpiresAt, }) http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: sess.CSRFToken, Path: "/", HttpOnly: false, Secure: secure, SameSite: http.SameSiteStrictMode, Expires: sess.ExpiresAt, }) } // clearSessionCookie blanks out the session and CSRF cookies. Used // on logout and on hard errors (DB failure, etc.). func clearSessionCookie(w http.ResponseWriter, r *http.Request) { secure := !isLoopbackRequest(r) for _, name := range []string{sessionCookieName, csrfCookieName} { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, Expires: time.Unix(0, 0), MaxAge: -1, }) } }