feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
173
internal/webapp/middleware.go
Обычный файл
173
internal/webapp/middleware.go
Обычный файл
@@ -0,0 +1,173 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user