// SPDX-License-Identifier: Apache-2.0 package auth import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "errors" "net/http" "time" "github.com/X4Applegate/caddyui/internal/models" "golang.org/x/crypto/bcrypt" ) const ( SessionCookie = "caddyui_session" SessionTTL = 7 * 24 * time.Hour ContextUserKey ctxKey = "user" ) type ctxKey string // bcryptCost is the work factor for NEW password hashes. SECURITY.md has always // promised 12; the code used bcrypt.DefaultCost (10) until v2.57.1. Existing // hashes keep verifying — the cost is stored inside each hash. const bcryptCost = 12 func HashPassword(pw string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(pw), bcryptCost) return string(h), err } func CheckPassword(hash, pw string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil } func newToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // HashSessionToken returns the at-rest representation of a session token. The // raw token lives only in the user's cookie; the sessions table stores its // SHA-256 hash so that a database disclosure (backup leak, snapshot, SQLi in // another table) cannot be replayed as a live session cookie. This mirrors the // api_tokens table, which is already stored hashed. func HashSessionToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func CreateSession(db *sql.DB, userID int64) (string, time.Time, error) { tok, err := newToken() if err != nil { return "", time.Time{}, err } expires := time.Now().Add(SessionTTL) _, err = db.Exec( `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, HashSessionToken(tok), userID, expires, ) if err != nil { return "", time.Time{}, err } return tok, expires, nil } // CreateSessionWithTTL works like CreateSession but uses a caller-supplied TTL. // Use this when the admin has configured a custom session duration. func CreateSessionWithTTL(db *sql.DB, userID int64, ttl time.Duration) (string, time.Time, error) { if ttl <= 0 { ttl = SessionTTL } tok, err := newToken() if err != nil { return "", time.Time{}, err } expires := time.Now().Add(ttl) _, err = db.Exec( `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, HashSessionToken(tok), userID, expires, ) if err != nil { return "", time.Time{}, err } return tok, expires, nil } // DeleteOtherSessions removes every session of the user except the one // identified by the raw cookie token keep. Used when the signed-in user changes // their own password: other devices are signed out, this one stays. func DeleteOtherSessions(db *sql.DB, userID int64, keep string) error { _, err := db.Exec(`DELETE FROM sessions WHERE user_id = ? AND token != ?`, userID, HashSessionToken(keep)) return err } // DeleteSession removes the session identified by the raw cookie token. func DeleteSession(db *sql.DB, token string) error { _, err := db.Exec(`DELETE FROM sessions WHERE token = ?`, HashSessionToken(token)) return err } func UserFromSession(db *sql.DB, token string) (*models.User, error) { if token == "" { return nil, errors.New("no session") } var userID int64 var expires time.Time err := db.QueryRow( `SELECT user_id, expires_at FROM sessions WHERE token = ?`, HashSessionToken(token), ).Scan(&userID, &expires) if err != nil { return nil, err } if time.Now().After(expires) { _ = DeleteSession(db, token) return nil, errors.New("expired") } return models.GetUserByID(db, userID) } // isSecure returns true when the request arrived over HTTPS (direct TLS or // via a reverse-proxy that sets X-Forwarded-Proto). func isSecure(r *http.Request) bool { return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" } func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { http.SetCookie(w, &http.Cookie{ Name: SessionCookie, Value: token, Path: "/", Expires: expires, HttpOnly: true, Secure: isSecure(r), SameSite: http.SameSiteLaxMode, }) } func ClearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: SessionCookie, Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, Secure: isSecure(r), SameSite: http.SameSiteLaxMode, }) }