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/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 } func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler { return &AuthHandler{cfg: cfg, stores: s} } 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 } 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 } 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) } 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[:]) } // 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, }) 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, }) 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 }