package auth import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/base64" "net/http" "time" "github.com/owen/vibeproxy/internal/database" ) type ctxKey string const actorCtxKey ctxKey = "actor" func ActorFromContext(ctx context.Context) string { if v, ok := ctx.Value(actorCtxKey).(string); ok { return v } return "unknown" } // BasicAuthAdmin protects the web UI with HTTP Basic Auth against the // single stored admin account. func BasicAuthAdmin(db *sql.DB) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok { unauthorized(w) return } user, err := database.GetUserByUsername(db, username) if err != nil { unauthorized(w) return } valid, err := VerifyPassword(password, user.PasswordHash) if err != nil || !valid { unauthorized(w) return } ctx := context.WithValue(r.Context(), actorCtxKey, username) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func unauthorized(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="vibeproxy"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) } // APIToken protects the REST API with a bearer token checked against // hashed tokens stored in the database. func APIToken(db *sql.DB) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tok := bearerToken(r) if tok == "" { http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized) return } hash := HashAPIToken(tok) rec, err := database.GetAPITokenByHash(db, hash) if err != nil || rec.RevokedAt != nil { http.Error(w, `{"error":"invalid or revoked token"}`, http.StatusUnauthorized) return } go database.TouchAPIToken(db, rec.ID) ctx := context.WithValue(r.Context(), actorCtxKey, "token:"+rec.Name) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func bearerToken(r *http.Request) string { h := r.Header.Get("Authorization") const p = "Bearer " if len(h) > len(p) && h[:len(p)] == p { return h[len(p):] } return "" } // CSRF implements a double-submit-cookie style CSRF protection for the // server-rendered web UI (which authenticates via Basic Auth and so has no // session to bind a per-session token to). type CSRF struct { secret []byte } func NewCSRF() *CSRF { s := make([]byte, 32) rand.Read(s) return &CSRF{secret: s} } const csrfCookieName = "vp_csrf" func (c *CSRF) sign(nonce string) string { mac := hmac.New(sha256.New, c.secret) mac.Write([]byte(nonce)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // Token issues (and sets a cookie for) a fresh CSRF token for rendering // into a form's hidden field. func (c *CSRF) Token(w http.ResponseWriter) string { nonceB := make([]byte, 16) rand.Read(nonceB) nonce := base64.RawURLEncoding.EncodeToString(nonceB) sig := c.sign(nonce) value := nonce + "." + sig http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: value, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: time.Now().Add(2 * time.Hour), }) return value } // Protect rejects unsafe requests whose form value doesn't match the // double-submitted cookie. func (c *CSRF) Protect(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { next.ServeHTTP(w, r) return } cookie, err := r.Cookie(csrfCookieName) if err != nil { http.Error(w, "CSRF token missing", http.StatusForbidden) return } formTok := r.FormValue("csrf_token") if formTok == "" || subtle.ConstantTimeCompare([]byte(formTok), []byte(cookie.Value)) != 1 { http.Error(w, "CSRF token invalid", http.StatusForbidden) return } next.ServeHTTP(w, r) }) }