545 lines
16 KiB
Go
545 lines
16 KiB
Go
package handlers
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"github.com/google/uuid"
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||
)
|
||
|
||
// Claims represents the JWT payload.
|
||
//
|
||
// bcryptCost is shared across auth.go, settings.go, admin.go
|
||
const bcryptCost = 12
|
||
|
||
type Claims struct {
|
||
UserID string `json:"user_id"`
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
type AuthHandler struct {
|
||
cfg *config.Config
|
||
stores store.Stores
|
||
uekCache *crypto.UEKCache
|
||
}
|
||
|
||
func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler {
|
||
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache}
|
||
}
|
||
|
||
func (h *AuthHandler) Register(c *gin.Context) {
|
||
var req struct {
|
||
Username string `json:"username" binding:"required"`
|
||
Email string `json:"email" binding:"required"`
|
||
Password string `json:"password" binding:"required,min=8"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Check registration policy
|
||
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_registration")
|
||
if !allowed {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
|
||
return
|
||
}
|
||
|
||
// Check duplicate
|
||
if existing, _ := h.stores.Users.GetByUsername(c.Request.Context(), req.Username); existing != nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
||
return
|
||
}
|
||
if existing, _ := h.stores.Users.GetByEmail(c.Request.Context(), req.Email); existing != nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
|
||
return
|
||
}
|
||
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||
return
|
||
}
|
||
|
||
// Check if user should be active by default
|
||
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
|
||
|
||
user := &models.User{
|
||
Username: strings.ToLower(req.Username),
|
||
Email: strings.ToLower(req.Email),
|
||
PasswordHash: string(hash),
|
||
Role: models.UserRoleUser,
|
||
IsActive: defaultActive,
|
||
}
|
||
|
||
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||
return
|
||
}
|
||
|
||
// Generate and store the User Encryption Key (vault)
|
||
if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil {
|
||
log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err)
|
||
// Non-fatal: user can still use the app, vault will init on next login
|
||
}
|
||
|
||
if !user.IsActive {
|
||
c.JSON(http.StatusCreated, gin.H{
|
||
"message": "Account created but requires admin approval",
|
||
"user_id": user.ID,
|
||
})
|
||
return
|
||
}
|
||
|
||
tokens, err := h.generateTokens(user)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusCreated, tokens)
|
||
}
|
||
|
||
func (h *AuthHandler) Login(c *gin.Context) {
|
||
var req struct {
|
||
Login string `json:"login" binding:"required"` // username or email
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
user, err := h.stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
||
if err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||
return
|
||
}
|
||
|
||
if !user.IsActive {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"})
|
||
return
|
||
}
|
||
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||
return
|
||
}
|
||
|
||
// Vault: unwrap UEK into session cache (or init if first login post-migration)
|
||
h.unlockVault(c.Request.Context(), user, req.Password)
|
||
|
||
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
|
||
|
||
tokens, err := h.generateTokens(user)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, tokens)
|
||
}
|
||
|
||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||
var req struct {
|
||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
tokenHash := hashToken(req.RefreshToken)
|
||
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
|
||
if err != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||
return
|
||
}
|
||
|
||
// Revoke the used token (rotate)
|
||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||
|
||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||
if err != nil || !user.IsActive {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
||
return
|
||
}
|
||
|
||
tokens, err := h.generateTokens(user)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, tokens)
|
||
}
|
||
|
||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||
var req struct {
|
||
RefreshToken string `json:"refresh_token"`
|
||
}
|
||
c.ShouldBindJSON(&req)
|
||
|
||
if req.RefreshToken != "" {
|
||
tokenHash := hashToken(req.RefreshToken)
|
||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||
}
|
||
|
||
// Evict UEK from session cache
|
||
if userID, exists := c.Get("user_id"); exists {
|
||
h.uekCache.Evict(userID.(string))
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||
}
|
||
|
||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||
// Access token (15 min)
|
||
accessClaims := Claims{
|
||
UserID: user.ID,
|
||
Email: user.Email,
|
||
Role: user.Role,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
ID: uuid.New().String(),
|
||
},
|
||
}
|
||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||
accessString, err := accessToken.SignedString([]byte(h.cfg.JWTSecret))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Refresh token (7 days)
|
||
refreshRaw := uuid.New().String()
|
||
refreshHash := hashToken(refreshRaw)
|
||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||
|
||
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
|
||
log.Printf("warn: failed to store refresh token: %v", err)
|
||
}
|
||
|
||
return gin.H{
|
||
"access_token": accessString,
|
||
"refresh_token": refreshRaw,
|
||
"token_type": "Bearer",
|
||
"expires_in": 900,
|
||
"user": gin.H{
|
||
"id": user.ID,
|
||
"username": user.Username,
|
||
"email": user.Email,
|
||
"display_name": user.DisplayName,
|
||
"role": user.Role,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func hashToken(token string) string {
|
||
h := sha256.Sum256([]byte(token))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
// ── Vault Lifecycle ─────────────────────────
|
||
|
||
// DestroyVaultDB nullifies a user's vault columns and deletes personal
|
||
// provider configs. This is the shared DB-level operation used by:
|
||
// - unlockVault (stale seal recovery)
|
||
// - BootstrapAdmin / SeedUsers (password rotation at startup)
|
||
// - AdminHandler.destroyVault (admin-initiated reset)
|
||
//
|
||
// Does NOT evict from UEK cache or write audit logs — callers handle that.
|
||
func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) {
|
||
_, err := database.DB.ExecContext(ctx, database.Q(`
|
||
UPDATE users
|
||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||
WHERE id = $1
|
||
`), userID)
|
||
if err != nil {
|
||
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
|
||
}
|
||
|
||
result, err := database.DB.ExecContext(ctx, database.Q(`
|
||
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
|
||
`), userID)
|
||
if err != nil {
|
||
log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err)
|
||
return 0
|
||
}
|
||
rows, _ := result.RowsAffected()
|
||
return rows
|
||
}
|
||
|
||
// ProbeAndRepairVault checks whether a user's vault can be unlocked with
|
||
// the given password. If the vault doesn't exist (vault_set=false), this is
|
||
// a no-op. If the vault exists but the seal is stale (encrypted_uek was
|
||
// wrapped with a different password), the vault and personal providers are
|
||
// destroyed so initVault fires cleanly on next login.
|
||
//
|
||
// Used by BootstrapAdmin and SeedUsers where the password is known at
|
||
// startup but the UEK cache is not available.
|
||
func ProbeAndRepairVault(ctx context.Context, userID, password string) {
|
||
var vaultSet bool
|
||
var encryptedUEK, salt, nonce []byte
|
||
|
||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||
FROM users WHERE id = $1
|
||
`), userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||
if err != nil || !vaultSet {
|
||
return // no vault to probe
|
||
}
|
||
|
||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||
if _, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk); err == nil {
|
||
return // vault seal matches current password — all good
|
||
}
|
||
|
||
// Stale seal: password has actually changed since the vault was sealed
|
||
deleted := DestroyVaultDB(ctx, userID)
|
||
if deleted > 0 {
|
||
log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted)
|
||
} else {
|
||
log.Printf(" 🔐 Vault stale-seal repair for user %s: vault columns cleared", userID)
|
||
}
|
||
}
|
||
|
||
// initVault generates a UEK, wraps it with the user's password, and stores
|
||
// the encrypted UEK + salt + nonce on the user record. Called on registration
|
||
// and on first login for pre-migration users.
|
||
func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error {
|
||
if h.uekCache == nil {
|
||
return nil // Vault not configured (e.g. tests)
|
||
}
|
||
|
||
uek, err := crypto.GenerateUEK()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
salt, err := crypto.GenerateSalt()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||
encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||
UPDATE users
|
||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
|
||
WHERE id = $4
|
||
`), encryptedUEK, salt, nonce, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Cache the UEK for the session
|
||
h.uekCache.Store(userID, uek)
|
||
log.Printf(" 🔐 Vault initialized for user %s", userID)
|
||
return nil
|
||
}
|
||
|
||
// unlockVault unwraps the UEK on login and caches it. If the user doesn't
|
||
// have a vault yet (pre-migration account), initializes one.
|
||
func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) {
|
||
if h.uekCache == nil {
|
||
return
|
||
}
|
||
|
||
var vaultSet bool
|
||
var encryptedUEK, salt, nonce []byte
|
||
|
||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||
FROM users WHERE id = $1
|
||
`), user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||
if err != nil {
|
||
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
|
||
return
|
||
}
|
||
|
||
if !vaultSet {
|
||
// Pre-migration user: initialize vault on first login
|
||
if err := h.initVault(ctx, user.ID, password); err != nil {
|
||
log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Derive PDK from password and unwrap UEK
|
||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
|
||
if err != nil {
|
||
// Stale seal: encrypted_uek was wrapped with a different password
|
||
// (e.g. admin reset, BootstrapAdmin rotation, or pre-UEK migration).
|
||
// The old UEK is irrecoverable. Destroy the vault and re-initialize
|
||
// with the current password so the user isn't permanently locked out.
|
||
deleted := DestroyVaultDB(ctx, user.ID)
|
||
if deleted > 0 {
|
||
log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted)
|
||
} else {
|
||
log.Printf("⚠ Vault stale-seal recovery for user %s: no personal providers to clear", user.ID)
|
||
}
|
||
|
||
if err := h.initVault(ctx, user.ID, password); err != nil {
|
||
log.Printf("⚠ Vault re-init failed for user %s: %v", user.ID, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
h.uekCache.Store(user.ID, uek)
|
||
}
|
||
|
||
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
|
||
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||
return
|
||
}
|
||
|
||
ctx := context.Background()
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
|
||
if err != nil {
|
||
log.Printf("⚠ Failed to hash admin password: %v", err)
|
||
return
|
||
}
|
||
|
||
existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
|
||
if existing != nil {
|
||
// Update password and ensure admin role
|
||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||
"password_hash": string(hash),
|
||
"role": models.UserRoleAdmin,
|
||
"is_active": true,
|
||
})
|
||
// If the actual password changed, the vault seal is stale. Probe it
|
||
// with the current password and destroy only if unwrap fails.
|
||
ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword)
|
||
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
|
||
return
|
||
}
|
||
|
||
email := cfg.AdminEmail
|
||
if email == "" {
|
||
email = cfg.AdminUsername + "@switchboard.local"
|
||
}
|
||
|
||
user := &models.User{
|
||
Username: strings.ToLower(cfg.AdminUsername),
|
||
Email: strings.ToLower(email),
|
||
PasswordHash: string(hash),
|
||
Role: models.UserRoleAdmin,
|
||
IsActive: true,
|
||
}
|
||
|
||
if err := s.Users.Create(ctx, user); err != nil {
|
||
log.Printf("⚠ Failed to create admin user: %v", err)
|
||
return
|
||
}
|
||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||
}
|
||
|
||
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user".
|
||
// Upsert: existing users get their password and role refreshed on every restart.
|
||
// Gated to non-production environments.
|
||
func SeedUsers(cfg *config.Config, s store.Stores) {
|
||
if cfg.SeedUsers == "" {
|
||
log.Printf(" ℹ SEED_USERS not set, skipping")
|
||
return
|
||
}
|
||
|
||
if cfg.Environment == "production" {
|
||
log.Printf("⚠ SEED_USERS ignored in production environment")
|
||
return
|
||
}
|
||
|
||
ctx := context.Background()
|
||
entries := strings.Split(cfg.SeedUsers, ",")
|
||
log.Printf(" 🌱 SEED_USERS: %d entries to process", len(entries))
|
||
|
||
for _, entry := range entries {
|
||
entry = strings.TrimSpace(entry)
|
||
if entry == "" {
|
||
continue
|
||
}
|
||
|
||
parts := strings.SplitN(entry, ":", 3)
|
||
if len(parts) < 2 {
|
||
log.Printf("⚠ Seed user skipped (bad format, want user:pass[:role]): %q", entry)
|
||
continue
|
||
}
|
||
|
||
username := strings.ToLower(strings.TrimSpace(parts[0]))
|
||
password := strings.TrimSpace(parts[1])
|
||
role := models.UserRoleUser
|
||
if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" {
|
||
role = models.UserRoleAdmin
|
||
}
|
||
|
||
if username == "" || password == "" {
|
||
log.Printf("⚠ Seed user skipped (empty username or password)")
|
||
continue
|
||
}
|
||
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||
if err != nil {
|
||
log.Printf("⚠ Seed user '%s' skipped (hash error): %v", username, err)
|
||
continue
|
||
}
|
||
|
||
// Upsert: update password+role if user exists, create if not
|
||
existing, _ := s.Users.GetByUsername(ctx, username)
|
||
if existing != nil {
|
||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||
"password_hash": string(hash),
|
||
"role": role,
|
||
"is_active": true,
|
||
})
|
||
// Probe vault with current password — only destroys if seal is stale
|
||
ProbeAndRepairVault(ctx, existing.ID, password)
|
||
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
|
||
continue
|
||
}
|
||
|
||
user := &models.User{
|
||
Username: username,
|
||
Email: username + "@switchboard.local",
|
||
PasswordHash: string(hash),
|
||
Role: role,
|
||
IsActive: true,
|
||
}
|
||
|
||
if err := s.Users.Create(ctx, user); err != nil {
|
||
log.Printf("⚠ Seed user '%s' failed: %v", username, err)
|
||
continue
|
||
}
|
||
log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role)
|
||
}
|
||
}
|
||
|
||
// IsRegistrationEnabled checks the platform policy.
|
||
func IsRegistrationEnabled(s store.Stores) bool {
|
||
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
||
return val
|
||
}
|