174 строки
5.5 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|