package handlers import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "log" "net/http" "os" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" ) const ( accessTokenDuration = 15 * time.Minute refreshTokenDuration = 7 * 24 * time.Hour bcryptCost = 12 ) // Claims is the JWT access token payload. type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` Role string `json:"role"` jwt.RegisteredClaims } // ── Request / Response types ──────────────── type registerRequest struct { Username string `json:"username" binding:"required,min=3,max=50"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8,max=128"` } type loginRequest struct { Login string `json:"login" binding:"required"` // email or username Password string `json:"password" binding:"required"` } type refreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` } type authResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` // seconds User userResponse `json:"user"` } type userResponse struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` DisplayName *string `json:"display_name"` Role string `json:"role"` } // AuthHandler holds dependencies for auth endpoints. type AuthHandler struct { cfg *config.Config } // NewAuthHandler creates a new auth handler. func NewAuthHandler(cfg *config.Config) *AuthHandler { return &AuthHandler{cfg: cfg} } // ── Register ──────────────────────────────── func (h *AuthHandler) Register(c *gin.Context) { var req registerRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Username = strings.TrimSpace(req.Username) // Check if this is the first user (will become admin) var userCount int _ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount) isFirstUser := userCount == 0 // First-user-becomes-admin only when no env admin is configured envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != "" promoteFirst := isFirstUser && !envAdminSet // If not first user (or env admin handles bootstrap), check registration if !promoteFirst { if !IsRegistrationEnabled() { c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"}) return } } // Hash password hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } // Determine role and active state role := "user" isActive := true if promoteFirst { role = "admin" } else { // Apply registration default state if GetRegistrationDefaultState() == "pending" { isActive = false } } // Insert user var user userResponse err = database.DB.QueryRow(` INSERT INTO users (username, email, password_hash, role, is_active) VALUES ($1, $2, $3, $4, $5) RETURNING id, username, email, display_name, role `, req.Username, req.Email, string(hash), role, isActive).Scan( &user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, ) if err != nil { if strings.Contains(err.Error(), "duplicate key") { field := "email" if strings.Contains(err.Error(), "username") { field = "username" } c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s already taken", field)}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) return } // If account is pending, don't generate tokens if !isActive { c.JSON(http.StatusCreated, gin.H{ "message": "Account created and pending admin approval", "pending": true, }) return } // Generate tokens resp, err := h.generateTokenPair(user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return } c.JSON(http.StatusCreated, resp) } // IsRegistrationEnabled checks the global_settings table. // Returns true if the table doesn't exist (pre-migration), DB is nil, or setting is enabled. func IsRegistrationEnabled() bool { if database.DB == nil { return true } var enabled bool err := database.DB.QueryRow(` SELECT COALESCE((value->>'value')::boolean, true) FROM global_settings WHERE key = 'registration_enabled' `).Scan(&enabled) if err != nil { return true // Default to open if setting missing } return enabled } // GetRegistrationDefaultState returns "active" or "pending". func GetRegistrationDefaultState() string { if database.DB == nil { return "active" } var state string err := database.DB.QueryRow(` SELECT COALESCE(value->>'value', 'active') FROM global_settings WHERE key = 'registration_default_state' `).Scan(&state) if err != nil || state == "" { return "active" } return state } // BootstrapAdmin creates or updates the admin user from environment variables. // This runs on every startup, so changing the K8s secret + restarting resets the password. // Handles both username and email conflicts (e.g. admin username changed between deploys). func BootstrapAdmin(cfg *config.Config) { if cfg.AdminUsername == "" || cfg.AdminPassword == "" { return } if database.DB == nil { return } email := cfg.AdminEmail if email == "" { email = cfg.AdminUsername + "@localhost" } hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost) if err != nil { log.Printf("⚠ Failed to hash admin password: %v", err) return } // Try upsert by username (common case: same username, new password) _, err = database.DB.Exec(` INSERT INTO users (username, email, password_hash, role, is_active) VALUES ($1, $2, $3, 'admin', true) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, email = EXCLUDED.email, role = 'admin', is_active = true `, cfg.AdminUsername, email, string(hash)) if err != nil && strings.Contains(err.Error(), "duplicate key") { // Email conflict — admin username was changed in config but email // already belongs to old admin row. Update that row instead. _, err = database.DB.Exec(` UPDATE users SET username = $1, password_hash = $3, role = 'admin', is_active = true WHERE email = $2 `, cfg.AdminUsername, email, string(hash)) } if err != nil { log.Printf("⚠ Admin bootstrap failed: %v", err) } else { log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername) } } // ── Login ─────────────────────────────────── func (h *AuthHandler) Login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Login = strings.TrimSpace(req.Login) // Look up user by email or username var user userResponse var passwordHash string var isActive bool err := database.DB.QueryRow(` SELECT id, username, email, display_name, role, password_hash, is_active FROM users WHERE email = $1 OR username = $1 `, req.Login).Scan( &user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &passwordHash, &isActive, ) if err == sql.ErrNoRows { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"}) return } if !isActive { c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"}) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } // Update last_login_at _, _ = database.DB.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID) // Generate tokens resp, err := h.generateTokenPair(user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return } c.JSON(http.StatusOK, resp) } // ── Refresh ───────────────────────────────── func (h *AuthHandler) Refresh(c *gin.Context) { var req refreshRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tokenHash := hashToken(req.RefreshToken) // Find and validate the refresh token var tokenID, userID string var expiresAt time.Time err := database.DB.QueryRow(` SELECT rt.id, rt.user_id, rt.expires_at FROM refresh_tokens rt WHERE rt.token_hash = $1 AND rt.revoked_at IS NULL `, tokenHash).Scan(&tokenID, &userID, &expiresAt) if err == sql.ErrNoRows { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "token validation failed"}) return } if time.Now().After(expiresAt) { // Revoke expired token _, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID) c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"}) return } // Revoke the old token (rotation) _, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID) // Look up user var user userResponse var isActive bool err = database.DB.QueryRow(` SELECT id, username, email, display_name, role, is_active FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &isActive, ) if err != nil || !isActive { c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"}) return } // Issue new pair resp, err := h.generateTokenPair(user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return } c.JSON(http.StatusOK, resp) } // ── Logout ────────────────────────────────── func (h *AuthHandler) Logout(c *gin.Context) { var req refreshRequest if err := c.ShouldBindJSON(&req); err != nil { // No refresh token provided — just acknowledge c.JSON(http.StatusOK, gin.H{"message": "logged out"}) return } tokenHash := hashToken(req.RefreshToken) // Revoke the refresh token _, _ = database.DB.Exec(` UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1 AND revoked_at IS NULL `, tokenHash) c.JSON(http.StatusOK, gin.H{"message": "logged out"}) } // ── Token Generation ──────────────────────── func (h *AuthHandler) generateTokenPair(user userResponse) (*authResponse, error) { now := time.Now() // Access token (JWT) claims := Claims{ UserID: user.ID, Email: user.Email, Role: user.Role, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)), Issuer: "chat-switchboard", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) accessToken, err := token.SignedString([]byte(h.cfg.JWTSecret)) if err != nil { return nil, fmt.Errorf("sign access token: %w", err) } // Refresh token (opaque random string, stored hashed) refreshBytes := make([]byte, 32) if _, err := rand.Read(refreshBytes); err != nil { return nil, fmt.Errorf("generate refresh token: %w", err) } refreshToken := hex.EncodeToString(refreshBytes) refreshHash := hashToken(refreshToken) // Store refresh token _, err = database.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshHash, now.Add(refreshTokenDuration)) if err != nil { return nil, fmt.Errorf("store refresh token: %w", err) } return &authResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(accessTokenDuration.Seconds()), User: user, }, nil } // hashToken returns a SHA-256 hex digest of a token string. // Refresh tokens are stored hashed so a DB leak doesn't // compromise active sessions. func hashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) }