Files
housecarl/internal/server/authn.go
T
abrown38andClaude Opus 5.5 de075edd5d Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:11:01 +01:00

293 lines
9.8 KiB
Go

package server
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/store"
"housecarl/internal/version"
)
const (
sessionCookie = "housecarl_session"
csrfHeader = "X-CSRF-Token"
)
// rateLimiter limits failed logins and token attempts per key (address or
// user name) over a sliding window.
type rateLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
const (
failWindow = 15 * time.Minute
failsPerIP = 20
failsPerKey = 8
)
func (l *rateLimiter) recent(key string, now time.Time) []time.Time {
var keep []time.Time
for _, t := range l.hits[key] {
if now.Sub(t) < failWindow {
keep = append(keep, t)
}
}
l.hits[key] = keep
return keep
}
func (l *rateLimiter) blocked(key string, max int) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= max
}
func (l *rateLimiter) fail(keys ...string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for _, k := range keys {
l.hits[k] = append(l.recent(k, now), now)
}
}
func (l *rateLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
func (l *rateLimiter) cleanup() {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
for k := range l.hits {
if len(l.recent(k, now)) == 0 {
delete(l.hits, k)
}
}
}
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer housecarl_...'"}
// authenticate identifies the caller: the local socket's peer, a bearer
// token or a session cookie (with the CSRF token for changes).
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
ip := s.clientIP(r)
if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" {
if trustedUID(pr.UID) {
return socketPrincipal(pr, r.UserAgent()), nil, nil
}
return nil, nil, socketDenied(pr)
}
if h := r.Header.Get("Authorization"); h != "" {
scheme, tok, _ := strings.Cut(h, " ")
if !strings.EqualFold(scheme, "Bearer") {
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"}
}
if s.limiter.blocked("ip:"+ip, failsPerIP) {
return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
}
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil {
s.limiter.fail("ip:" + ip)
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
}
return p, nil, nil
}
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return nil, nil, errUnauthenticated
}
hash := auth.HashSecret(c.Value)
se, err := s.store.GetSession(r.Context(), hash)
if err != nil {
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
}
now := time.Now()
if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle {
s.store.DeleteSession(r.Context(), hash)
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
}
u, err := s.store.GetUser(r.Context(), se.UserID)
if err != nil || u.Disabled {
s.store.DeleteSession(r.Context(), hash)
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"}
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
got := r.Header.Get(csrfHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 {
return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"}
}
}
if now.Sub(se.LastSeenAt) > time.Minute {
s.store.TouchSession(r.Context(), hash, now)
}
return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil
}
func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) {
pid, secret, err := auth.ParseToken(tok)
if err != nil {
return nil, err
}
t, err := s.store.GetTokenByPublicID(ctx, pid)
if errors.Is(err, store.ErrNotFound) {
return nil, errors.New("unknown token")
}
if err != nil {
return nil, err
}
now := time.Now()
if !auth.CheckSecret(secret, t.SecretHash) {
// After a rotation with a grace period the old secret works until
// the grace period ends.
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
return nil, errors.New("wrong token secret")
}
if now.After(*t.PrevExpiresAt) {
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
}
s.log.Warn("token used with its old secret; give this client the new one before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
if t.RevokedAt != nil {
return nil, errors.New("the token was revoked")
}
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
return nil, errors.New("the token has expired")
}
u, err := s.store.GetUser(ctx, t.UserID)
if err != nil {
return nil, err
}
if u.Disabled {
return nil, errors.New("the token's user is disabled")
}
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
s.store.TouchToken(ctx, t.ID, ip)
}
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
}
// authed wraps a handler with authentication and error handling.
func (s *Server) authed(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
p, se, err := s.authenticate(r)
if err != nil {
s.writeErr(w, r, err, nil)
return
}
ctx := context.WithValue(r.Context(), keyPrincipal, p)
if se != nil {
ctx = context.WithValue(ctx, keySession, se)
}
r = r.WithContext(ctx)
if err := h(w, r, p); err != nil {
s.writeErr(w, r, err, nil)
}
}
}
// public wraps an unauthenticated handler.
func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := h(w, r); err != nil {
s.writeErr(w, r, err, nil)
}
}
}
func (s *Server) cookieSecure(r *http.Request) bool {
switch s.cfg.CookieSecure {
case "always":
return true
case "never":
return false
}
return s.isHTTPS(r)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
var in api.Login
if err := decodeJSON(r, &in); err != nil {
return err
}
ip := s.clientIP(r)
name := strings.TrimSpace(in.Username)
userKey := "user:" + strings.ToLower(name)
who := &auth.Principal{User: &model.User{Username: name}, IP: ip, Method: "session"}
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user "+name, "denied", "too many failed attempts")
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
}
u, err := s.store.GetUserByName(r.Context(), name)
hash := ""
if err == nil {
hash = u.PasswordHash
}
ok := auth.CheckPassword(hash, in.Password)
if err != nil || !ok || u.Disabled || u.PasswordHash == "" {
s.limiter.fail("ip:"+ip, userKey)
reason := "wrong username or password"
if err == nil && u.Disabled && ok {
reason = "account disabled"
}
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
}
s.limiter.reset(userKey)
id, csrf := auth.NewSessionID()
now := time.Now()
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()}
if err := s.store.CreateSession(r.Context(), se); err != nil {
return err
}
s.store.TouchLogin(r.Context(), u.ID)
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
s.eng.Audit(r.Context(), p, "auth.login", "user "+u.Username, "success", "")
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
return nil
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
s.store.DeleteSession(r.Context(), se.IDHash)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1})
s.eng.Audit(r.Context(), p, "auth.logout", "user "+p.Name(), "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info(), Mode: s.mode()}
if se != nil {
out.CSRFToken = se.CSRFToken
}
if p.Token != nil {
out.TokenID = p.Token.ID
}
return out
}
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
se, _ := r.Context().Value(keySession).(*model.Session)
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
return nil
}