mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
274 lines
7.8 KiB
Go
274 lines
7.8 KiB
Go
package labeler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
sessionCookieName = "labeler_session"
|
|
sessionTTL = 24 * time.Hour
|
|
csrfHeaderName = "X-CSRF-Token"
|
|
csrfFormField = "csrf_token"
|
|
)
|
|
|
|
// Session represents an authenticated admin session. Restart wipes the in-memory
|
|
// map so any stolen cookie token becomes useless after a restart, by design.
|
|
//
|
|
// UserAgent and IPPrefix are captured at login and rechecked on every request —
|
|
// a stolen token replayed from a different browser or network prefix is rejected
|
|
// and the session is torn down. Empty bound values (Unix sockets, tests, unusual
|
|
// proxies) opt out rather than locking users out. Binding at /24 (IPv4) / /64
|
|
// (IPv6) tolerates DHCP renewals within a prefix without inviting cross-network
|
|
// replay.
|
|
type Session struct {
|
|
DID string
|
|
Handle string
|
|
CSRFToken string
|
|
CreatedAt time.Time
|
|
UserAgent string
|
|
IPPrefix string
|
|
}
|
|
|
|
// Auth manages in-memory admin sessions for the labeler.
|
|
type Auth struct {
|
|
ownerDID string
|
|
sessions map[string]*Session
|
|
sessionsMu sync.RWMutex
|
|
}
|
|
|
|
// NewAuth wires a fresh in-memory session store keyed to the configured owner DID.
|
|
func NewAuth(ownerDID string) *Auth {
|
|
return &Auth{
|
|
ownerDID: ownerDID,
|
|
sessions: make(map[string]*Session),
|
|
}
|
|
}
|
|
|
|
func randToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("generate token: %w", err)
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// CreateSession installs a new in-memory session and returns its cookie token
|
|
// alongside the embedded CSRF token (for echoing into forms).
|
|
func (a *Auth) CreateSession(did, handle, userAgent, ipPrefix string) (string, *Session, error) {
|
|
token, err := randToken()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
csrfToken, err := randToken()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
s := &Session{
|
|
DID: did,
|
|
Handle: handle,
|
|
CSRFToken: csrfToken,
|
|
CreatedAt: time.Now(),
|
|
UserAgent: userAgent,
|
|
IPPrefix: ipPrefix,
|
|
}
|
|
a.sessionsMu.Lock()
|
|
a.sessions[token] = s
|
|
a.sessionsMu.Unlock()
|
|
return token, s, nil
|
|
}
|
|
|
|
// GetSession returns the session for the cookie token, evicting expired entries on access.
|
|
func (a *Auth) GetSession(token string) *Session {
|
|
a.sessionsMu.RLock()
|
|
s := a.sessions[token]
|
|
a.sessionsMu.RUnlock()
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if !s.CreatedAt.IsZero() && time.Since(s.CreatedAt) > sessionTTL {
|
|
a.sessionsMu.Lock()
|
|
delete(a.sessions, token)
|
|
a.sessionsMu.Unlock()
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// DeleteSession removes a session by cookie token (logout).
|
|
func (a *Auth) DeleteSession(token string) {
|
|
a.sessionsMu.Lock()
|
|
delete(a.sessions, token)
|
|
a.sessionsMu.Unlock()
|
|
}
|
|
|
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(sessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func clearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func getSessionCookie(r *http.Request) (string, bool) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return cookie.Value, true
|
|
}
|
|
|
|
type sessionContextKeyT struct{}
|
|
|
|
var sessionContextKey = sessionContextKeyT{}
|
|
|
|
// SessionFromContext returns the session attached to the request context, if any.
|
|
func SessionFromContext(ctx context.Context) *Session {
|
|
s, _ := ctx.Value(sessionContextKey).(*Session)
|
|
return s
|
|
}
|
|
|
|
// RequireOwner enforces a valid session bound to the owner DID, with UA / IP-prefix
|
|
// replay defense. State-mutating methods then go through the CSRF check below.
|
|
func (a *Auth) RequireOwner(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, ok := getSessionCookie(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
|
return
|
|
}
|
|
session := a.GetSession(token)
|
|
if session == nil {
|
|
clearSessionCookie(w)
|
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
|
return
|
|
}
|
|
if session.DID != a.ownerDID {
|
|
a.DeleteSession(token)
|
|
clearSessionCookie(w)
|
|
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
|
|
return
|
|
}
|
|
if session.UserAgent != "" && session.UserAgent != r.UserAgent() {
|
|
slog.Warn("Admin session UA mismatch — suspected token replay", "did", session.DID)
|
|
a.DeleteSession(token)
|
|
clearSessionCookie(w)
|
|
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
|
|
return
|
|
}
|
|
if session.IPPrefix != "" {
|
|
if now := clientIPPrefix(r); now != "" && now != session.IPPrefix {
|
|
slog.Warn("Admin session IP-prefix mismatch — suspected token replay",
|
|
"did", session.DID, "session", session.IPPrefix, "request", now)
|
|
a.DeleteSession(token)
|
|
clearSessionCookie(w)
|
|
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// RequireCSRF validates a per-session CSRF token on state-mutating requests. Safe
|
|
// methods pass through. Token comes from X-CSRF-Token header or the csrf_token form
|
|
// field for application/x-www-form-urlencoded bodies. Must run after RequireOwner.
|
|
func (a *Auth) RequireCSRF(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
session := SessionFromContext(r.Context())
|
|
if session == nil || session.CSRFToken == "" {
|
|
http.Error(w, "Forbidden: missing session", http.StatusForbidden)
|
|
return
|
|
}
|
|
got := r.Header.Get(csrfHeaderName)
|
|
if got == "" {
|
|
contentType := r.Header.Get("Content-Type")
|
|
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
|
|
contentType = contentType[:idx]
|
|
}
|
|
contentType = strings.TrimSpace(strings.ToLower(contentType))
|
|
if contentType == "application/x-www-form-urlencoded" {
|
|
if err := r.ParseForm(); err == nil {
|
|
got = r.PostFormValue(csrfFormField)
|
|
}
|
|
}
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
|
|
slog.Warn("Labeler CSRF mismatch", "path", r.URL.Path, "did", session.DID)
|
|
http.Error(w, "Forbidden: CSRF token mismatch — reload the page and try again.", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// csrfInputHTML emits a hidden form input carrying the per-session CSRF token.
|
|
func csrfInputHTML(token string) template.HTML {
|
|
escaped := template.HTMLEscapeString(token)
|
|
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
|
|
}
|
|
|
|
// clientIPPrefix returns a stable prefix key for the request's client IP — /24 for
|
|
// IPv4, /64 for IPv6. Empty string means "don't bind" (avoids locking users behind
|
|
// unusual proxies / Unix sockets / tests).
|
|
func clientIPPrefix(r *http.Request) string {
|
|
var host string
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
if before, _, ok := strings.Cut(fwd, ","); ok {
|
|
host = strings.TrimSpace(before)
|
|
} else {
|
|
host = strings.TrimSpace(fwd)
|
|
}
|
|
} else {
|
|
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err == nil {
|
|
host = h
|
|
} else {
|
|
host = r.RemoteAddr
|
|
}
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return ""
|
|
}
|
|
if v4 := ip.To4(); v4 != nil {
|
|
return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2])
|
|
}
|
|
v6 := ip.To16()
|
|
return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x",
|
|
v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7])
|
|
}
|