Files
worker/internal/webapp/middleware.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

174 строки
5.5 KiB
Go

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