Files
veola/internal/auth/auth.go
prosolis edb732ee1f Auction end times, visual flair, and pre-launch cleanup
Auction handling:
- Capture itemEndDate from eBay Browse API and ending_date from ZenMarket
  (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket
  parser (multiple layouts, JST when offset missing).
- Per-row "Ends" countdown column + "Ending soon" banner on results pages,
  live-ticked by flair.js with urgent/critical tinting under 1h/5m.
- Backfill ends_at for known auctions when their URL reappears in a poll
  (dedup hit no longer drops the new end time).
- Hide ended auctions from result listings by default via
  ResultsQuery.ExcludeEnded; rows stay in the DB.

Visual flair:
- Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift.
- htmx swap fade-in via transient .v-just-swapped class.
- Count-up animation on dashboard stats. All animations gated behind
  prefers-reduced-motion.

eBay condition + region filters (auctions-style scoping):
- items.condition and items.region columns; threaded through item form,
  CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so
  cache invalidates when these change.
- ebay.SearchParams gains conditionIds and itemLocationCountry filters.

Run Now reload + countdown engine:
- Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so
  the entire results view — best price, chart, badge, last polled —
  reflects the new poll, instead of swapping just one partial.

Pre-launch hardening (P1 set):
- auth.EqualizeLoginTiming on no-such-user branch.
- (*App).serverError centralizes 500s; replaces err.Error() leaks across
  results/settings/items/users/dashboard handlers.
- main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s
  alongside the existing ReadHeaderTimeout.
- noListFS wrapper blocks static directory listings.
- Credential fields in settings no longer render value=; blank submission
  preserves the saved value, with per-field "Saved in settings / Set in
  config.toml / Not set" status indicator.

Misc:
- -debug flag wires slog to LevelDebug; raw ZenMarket items logged for
  format diagnosis.
- /healthz public endpoint for reverse-proxy probes.
- deploy/veola.service systemd unit template (hardening flags, single
  ReadWritePaths=/var/lib/veola).
- handlers_test.go covers /healthz, setup-gate redirect, auth gate, and
  /login render with httptest + in-memory sqlite.
- best_price_currency on items; templates pick the right symbol per row.
- .gitignore now excludes *.log / veola-debug.log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:47:09 -07:00

231 lines
6.5 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
}
// 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))
}