package handlers import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "net/http" "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 // If not first user, check if registration is enabled if !isFirstUser { 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 } // First user gets admin role role := "user" if isFirstUser { role = "admin" } // Insert user var user userResponse err = database.DB.QueryRow(` INSERT INTO users (username, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id, username, email, display_name, role `, req.Username, req.Email, string(hash), role).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 } // 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->>'enabled')::boolean, true) FROM global_settings WHERE key = 'registration' `).Scan(&enabled) if err != nil { return true // Default to open if setting missing } return enabled } // ── 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 disabled"}) 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[:]) }