Files
veola/internal/auth/auth.go
prosolis fd1682e11b Harden for public deployment behind a reverse proxy
The session cookie now sets the Secure attribute (server.secure_cookies, default true). Adds chi RealIP and Recoverer middleware plus a securityHeaders middleware that emits a Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy on every response. HSTS is intentionally left to the TLS-terminating proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:50 -07:00

218 lines
5.8 KiB
Go

package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"time"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
"golang.org/x/crypto/bcrypt"
"veola/internal/db"
"veola/internal/models"
)
const (
BcryptCost = 12
MinPasswordLen = 12
sessionUserIDKey = "user_id"
sessionCSRFKey = "csrf_token"
csrfFormField = "csrf_token"
csrfHeaderName = "X-CSRF-Token"
)
// Manager bundles session manager + DB store and serves as the auth surface.
type Manager struct {
Sessions *scs.SessionManager
Store *db.Store
hmacKey []byte
}
func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) {
if len(sessionSecret) < 32 {
return nil, errors.New("session secret too short")
}
sm := scs.New()
sm.Store = sqlite3store.New(sqlDB)
sm.Lifetime = 7 * 24 * time.Hour
sm.IdleTimeout = 7 * 24 * time.Hour
sm.Cookie.Name = "veola_session"
sm.Cookie.HttpOnly = true
sm.Cookie.Path = "/"
sm.Cookie.SameSite = http.SameSiteLaxMode
sm.Cookie.Persist = true
// Secure must be set whenever the browser-facing connection is HTTPS,
// which includes running behind a TLS-terminating proxy. Resolved from
// config; defaults to true there.
sm.Cookie.Secure = secureCookies
mac := sha256.New()
mac.Write([]byte(sessionSecret))
return &Manager{Sessions: sm, Store: store, hmacKey: mac.Sum(nil)}, nil
}
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(b), nil
}
func CheckPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// LogIn writes the user id into the session and rotates the token.
func (m *Manager) LogIn(ctx context.Context, userID int64) error {
if err := m.Sessions.RenewToken(ctx); err != nil {
return err
}
m.Sessions.Put(ctx, sessionUserIDKey, userID)
m.Sessions.Put(ctx, sessionCSRFKey, newCSRFToken())
return nil
}
func (m *Manager) LogOut(ctx context.Context) error {
return m.Sessions.Destroy(ctx)
}
func (m *Manager) UserID(ctx context.Context) int64 {
return m.Sessions.GetInt64(ctx, sessionUserIDKey)
}
func (m *Manager) CurrentUser(ctx context.Context) (*models.User, error) {
id := m.UserID(ctx)
if id == 0 {
return nil, nil
}
return m.Store.GetUserByID(ctx, id)
}
// ============ CSRF ============
func newCSRFToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// extremely unlikely; fall back to a time-based token rather than crashing
return hex.EncodeToString([]byte(time.Now().String()))
}
return hex.EncodeToString(b)
}
func (m *Manager) CSRFToken(ctx context.Context) string {
tok := m.Sessions.GetString(ctx, sessionCSRFKey)
if tok == "" {
tok = newCSRFToken()
m.Sessions.Put(ctx, sessionCSRFKey, tok)
}
return tok
}
// CSRFFieldName is the HTML form field expected by the middleware.
func CSRFFieldName() string { return csrfFormField }
// ============ Middleware ============
type ctxKey int
const (
ctxKeyUser ctxKey = iota
)
func userFromContext(ctx context.Context) *models.User {
u, _ := ctx.Value(ctxKeyUser).(*models.User)
return u
}
// CurrentUserFromRequest is the public accessor for handlers and templates.
func CurrentUserFromRequest(r *http.Request) *models.User {
return userFromContext(r.Context())
}
// LoadUser populates the user into the context for any logged-in session.
// Routes still need RequireAuth/RequireAdmin to gate access.
func (m *Manager) LoadUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := m.CurrentUser(r.Context())
if err == nil && u != nil {
ctx := context.WithValue(r.Context(), ctxKeyUser, u)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
// RequireAuth redirects to /login if no user is present. Skips /login, /setup,
// /static. The setup-gate (redirect to /setup if no users exist) is applied
// at the router level via SetupGate so it can short-circuit before auth runs.
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if userFromContext(r.Context()) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (m *Manager) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r.Context())
if u == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if u.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// CSRFProtect validates the CSRF token on non-idempotent requests.
func (m *Manager) CSRFProtect(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
}
expected := m.Sessions.GetString(r.Context(), sessionCSRFKey)
if expected == "" {
http.Error(w, "csrf token missing from session", http.StatusForbidden)
return
}
got := r.Header.Get(csrfHeaderName)
if got == "" {
if err := r.ParseForm(); err == nil {
got = r.PostFormValue(csrfFormField)
}
}
if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
http.Error(w, "invalid csrf token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// HMAC is exposed for non-session use cases (e.g. signed setup links). Not
// currently called by handlers but kept available since the secret is loaded.
func (m *Manager) HMAC(payload []byte) string {
h := hmac.New(sha256.New, m.hmacKey)
h.Write(payload)
return hex.EncodeToString(h.Sum(nil))
}