215 lines
5.7 KiB
Go
215 lines
5.7 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) (*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
|
|
// Cookie.Secure left false for self-hosted HTTP deployments; flip via env in deploy.
|
|
|
|
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))
|
|
}
|