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