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