Files
veola/internal/auth/auth.go
prosolis 7c95e4fd4e Add Authentik SSO, budget visibility, Resend email, 12h item default
Opens Veola up to more users (Parodia's Authentik) and makes Apify spend
visible to everyone.

- Auth: Traefik forward-auth (trust X-Authentik-* headers only from
  trusted_proxies CIDRs, keyed by email, role synced from admin_group),
  keeping local password login as break-glass. New [auth] config,
  CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md.
- Budget: count every Apify run (apify_api_usage table) and show
  calls + estimated cost to all users on the dashboard, with an optional
  monthly-budget bar. New [budget] config + settings.
- Email: Resend client for opt-in deal alerts and a weekly digest
  (Mon 09:00). Per-user email + toggles on Settings. New [resend] config.
- Defaults: new items default to a 12-hour poll interval to cut spend.

users table gains email/auth_source/email-pref columns (migrated in place).
go build/vet/test green; boots and migrates cleanly.
2026-06-20 11:12:11 -07:00

234 lines
6.6 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
// forward, when non-nil, enables trust of reverse-proxy identity headers.
// See forward.go.
forward *ForwardAuthConfig
}
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
}
// dummyHash is a valid bcrypt hash of a throwaway value. It exists only so a
// login attempt for a non-existent user can still run a real bcrypt
// comparison, equalizing response time and closing the user-enumeration
// timing oracle.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("veola-timing-equalizer"), BcryptCost)
// EqualizeLoginTiming performs a bcrypt comparison against a throwaway hash so
// that a missing username costs roughly the same wall-clock time as a wrong
// password. Call it on the no-such-user branch of login.
func EqualizeLoginTiming() {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte("veola-timing-equalizer-x"))
}
// 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))
}