Files
vibeproxy/internal/auth/middleware.go
T
owenandClaude Sonnet 5 ba1f7c4c03 Initial vibeproxy implementation
Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go,
with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS
framework), and a REST API under /api/v1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
2026-08-17 10:28:59 -05:00

152 lines
4.0 KiB
Go

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)
})
}