From 8f10352e7dc8705bb7179a2b78da444f8c391786 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 15 Feb 2026 23:41:48 +0000 Subject: [PATCH] Issue 4 jwt (#24) --- migrations/002_refresh_tokens.sql | 20 ++ server/go.mod | 2 +- server/handlers/auth.go | 323 +++++++++++++++++++++++++++++ server/handlers/auth_test.go | 331 ++++++++++++++++++++++++++++++ server/main.go | 66 +++--- server/main_test.go | 87 +++++--- server/middleware/auth.go | 21 +- server/middleware/cors.go | 100 --------- server/middleware/ratelimit.go | 92 +++++++++ 9 files changed, 883 insertions(+), 159 deletions(-) create mode 100644 migrations/002_refresh_tokens.sql create mode 100644 server/handlers/auth.go create mode 100644 server/handlers/auth_test.go delete mode 100644 server/middleware/cors.go create mode 100644 server/middleware/ratelimit.go diff --git a/migrations/002_refresh_tokens.sql b/migrations/002_refresh_tokens.sql new file mode 100644 index 0000000..8eb6573 --- /dev/null +++ b/migrations/002_refresh_tokens.sql @@ -0,0 +1,20 @@ +-- ========================================== +-- Chat Switchboard - Refresh Tokens +-- ========================================== +-- Supports JWT refresh token rotation. +-- Old tokens are revoked on each refresh. + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + revoked_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL; + +-- Cleanup: auto-delete expired tokens older than 30 days +-- (run periodically or via pg_cron if available) diff --git a/server/go.mod b/server/go.mod index 7c1d6dd..8c6c813 100644 --- a/server/go.mod +++ b/server/go.mod @@ -7,6 +7,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.14.0 ) require ( @@ -28,7 +29,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.14.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect diff --git a/server/handlers/auth.go b/server/handlers/auth.go new file mode 100644 index 0000000..4f0d902 --- /dev/null +++ b/server/handlers/auth.go @@ -0,0 +1,323 @@ +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) + + // 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 + } + + // Insert user + var user userResponse + err = database.DB.QueryRow(` + INSERT INTO users (username, email, password_hash, role) + VALUES ($1, $2, $3, 'user') + RETURNING id, username, email, display_name, role + `, req.Username, req.Email, string(hash)).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) +} + +// ── 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[:]) +} diff --git a/server/handlers/auth_test.go b/server/handlers/auth_test.go new file mode 100644 index 0000000..788259a --- /dev/null +++ b/server/handlers/auth_test.go @@ -0,0 +1,331 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "git.gobha.me/xcaliber/chat-switchboard/config" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func testConfig() *config.Config { + return &config.Config{ + JWTSecret: "test-secret-key-for-unit-tests", + } +} + +// ── JWT Token Tests ───────────────────────── + +func TestJWTGeneration(t *testing.T) { + cfg := testConfig() + + // Create a token the same way the handler does + now := time.Now() + claims := Claims{ + UserID: "550e8400-e29b-41d4-a716-446655440000", + Email: "test@example.com", + Role: "user", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + Issuer: "chat-switchboard", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(cfg.JWTSecret)) + if err != nil { + t.Fatalf("Failed to sign token: %v", err) + } + + if tokenString == "" { + t.Fatal("Token string is empty") + } + + // Parse it back + parsed := &Claims{} + parsedToken, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) { + return []byte(cfg.JWTSecret), nil + }) + if err != nil { + t.Fatalf("Failed to parse token: %v", err) + } + if !parsedToken.Valid { + t.Fatal("Parsed token is not valid") + } + if parsed.UserID != claims.UserID { + t.Errorf("UserID mismatch: got %s, want %s", parsed.UserID, claims.UserID) + } + if parsed.Email != claims.Email { + t.Errorf("Email mismatch: got %s, want %s", parsed.Email, claims.Email) + } + if parsed.Role != claims.Role { + t.Errorf("Role mismatch: got %s, want %s", parsed.Role, claims.Role) + } +} + +func TestJWTExpired(t *testing.T) { + cfg := testConfig() + + claims := Claims{ + UserID: "test-user", + Email: "test@example.com", + Role: "user", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, _ := token.SignedString([]byte(cfg.JWTSecret)) + + parsed := &Claims{} + _, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) { + return []byte(cfg.JWTSecret), nil + }) + if err == nil { + t.Fatal("Expected error for expired token, got nil") + } +} + +func TestJWTWrongSecret(t *testing.T) { + claims := Claims{ + UserID: "test-user", + Email: "test@example.com", + Role: "user", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, _ := token.SignedString([]byte("correct-secret")) + + parsed := &Claims{} + _, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) { + return []byte("wrong-secret"), nil + }) + if err == nil { + t.Fatal("Expected error for wrong secret, got nil") + } +} + +// ── Password Hashing Tests ───────────────── + +func TestBcryptHash(t *testing.T) { + password := "mySecurePassword123!" + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + t.Fatalf("Failed to hash: %v", err) + } + + // Correct password should match + err = bcrypt.CompareHashAndPassword(hash, []byte(password)) + if err != nil { + t.Error("Correct password should match hash") + } + + // Wrong password should not match + err = bcrypt.CompareHashAndPassword(hash, []byte("wrongPassword")) + if err == nil { + t.Error("Wrong password should not match hash") + } +} + +func TestBcryptDifferentHashesForSamePassword(t *testing.T) { + password := "samePassword" + + hash1, _ := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + + if string(hash1) == string(hash2) { + t.Error("Same password should produce different hashes (salt)") + } + + // Both should still verify + if bcrypt.CompareHashAndPassword(hash1, []byte(password)) != nil { + t.Error("hash1 should verify") + } + if bcrypt.CompareHashAndPassword(hash2, []byte(password)) != nil { + t.Error("hash2 should verify") + } +} + +// ── Token Hashing Tests ──────────────────── + +func TestTokenHash(t *testing.T) { + token := "abc123refreshtoken" + hash := hashToken(token) + + // Should be deterministic + if hashToken(token) != hash { + t.Error("hashToken should be deterministic") + } + + // Different token should produce different hash + if hashToken("different") == hash { + t.Error("Different tokens should produce different hashes") + } + + // Should be hex-encoded SHA-256 (64 chars) + if len(hash) != 64 { + t.Errorf("Expected 64 char hex hash, got %d chars", len(hash)) + } + + // Verify it's actually SHA-256 + expected := sha256.Sum256([]byte(token)) + expectedHex := hex.EncodeToString(expected[:]) + if hash != expectedHex { + t.Errorf("Hash mismatch: got %s, want %s", hash, expectedHex) + } +} + +// ── Request Validation Tests ──────────────── + +func TestRegisterValidation(t *testing.T) { + h := NewAuthHandler(testConfig()) + + tests := []struct { + name string + body string + wantCode int + }{ + { + name: "missing fields", + body: `{}`, + wantCode: http.StatusBadRequest, + }, + { + name: "missing password", + body: `{"username":"test","email":"test@example.com"}`, + wantCode: http.StatusBadRequest, + }, + { + name: "invalid email", + body: `{"username":"test","email":"notanemail","password":"12345678"}`, + wantCode: http.StatusBadRequest, + }, + { + name: "password too short", + body: `{"username":"test","email":"test@example.com","password":"short"}`, + wantCode: http.StatusBadRequest, + }, + { + name: "username too short", + body: `{"username":"ab","email":"test@example.com","password":"12345678"}`, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/auth/register", + strings.NewReader(tt.body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Register(c) + + if w.Code != tt.wantCode { + t.Errorf("got status %d, want %d. Body: %s", + w.Code, tt.wantCode, w.Body.String()) + } + }) + } +} + +func TestLoginValidation(t *testing.T) { + h := NewAuthHandler(testConfig()) + + tests := []struct { + name string + body string + wantCode int + }{ + { + name: "missing fields", + body: `{}`, + wantCode: http.StatusBadRequest, + }, + { + name: "missing password", + body: `{"login":"test@example.com"}`, + wantCode: http.StatusBadRequest, + }, + { + name: "missing login", + body: `{"password":"12345678"}`, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/auth/login", + strings.NewReader(tt.body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Login(c) + + if w.Code != tt.wantCode { + t.Errorf("got status %d, want %d", w.Code, tt.wantCode) + } + }) + } +} + +func TestRefreshValidation(t *testing.T) { + h := NewAuthHandler(testConfig()) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/auth/refresh", + strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Refresh(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing refresh_token, got %d", w.Code) + } +} + +func TestLogoutWithoutToken(t *testing.T) { + h := NewAuthHandler(testConfig()) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/auth/logout", + strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Logout(c) + + if w.Code != http.StatusOK { + t.Errorf("Logout without token should succeed, got %d", w.Code) + } + + var resp map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "logged out" { + t.Errorf("Expected 'logged out' message, got %s", resp["message"]) + } +} diff --git a/server/main.go b/server/main.go index 53cdb63..ad23128 100644 --- a/server/main.go +++ b/server/main.go @@ -7,41 +7,47 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/middleware" ) func main() { - // Load configuration from environment cfg := config.Load() - // Connect to database (optional — runs without it in unmanaged mode) if err := database.Connect(cfg); err != nil { log.Printf("⚠ Database unavailable: %v", err) log.Println(" Running in unmanaged mode (no persistence)") } defer database.Close() - // Initialize router r := gin.Default() r.Use(middleware.CORS()) // Health check r.GET("/health", func(c *gin.Context) { - status := gin.H{ + c.JSON(200, gin.H{ "status": "ok", + "version": Version, "database": database.IsConnected(), - } - c.JSON(200, status) + }) }) - // API routes + // ── Auth routes (rate limited) ────────────── + auth := handlers.NewAuthHandler(cfg) + authLimiter := middleware.NewRateLimiter(1, 5) // 1 req/s, burst of 5 + api := r.Group("/api/v1") { - // Public auth routes - api.POST("/auth/register", handleRegister) - api.POST("/auth/login", handleLogin) + authGroup := api.Group("/auth") + authGroup.Use(authLimiter.Limit()) + { + authGroup.POST("/register", auth.Register) + authGroup.POST("/login", auth.Login) + authGroup.POST("/refresh", auth.Refresh) + authGroup.POST("/logout", auth.Logout) + } - // Protected routes + // ── Protected routes ──────────────────── protected := api.Group("") protected.Use(middleware.Auth(cfg)) { @@ -65,30 +71,32 @@ func main() { protected.POST("/api-configs", handleCreateAPIConfig) protected.DELETE("/api-configs/:id", handleDeleteAPIConfig) - // Models (proxy to configured API) + // Models protected.GET("/models", handleListModels) } } - log.Printf("🔀 Chat Switchboard API starting on port %s", cfg.Port) + log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) } } -// Placeholder handlers — will be moved to handlers/ package -func handleRegister(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleLogin(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleDeleteAPIConfig(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +// Placeholder handlers — ticket #9 +func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } +func handleCreateAPIConfig(c *gin.Context) { + c.JSON(501, gin.H{"error": "not implemented"}) +} +func handleDeleteAPIConfig(c *gin.Context) { + c.JSON(501, gin.H{"error": "not implemented"}) +} +func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } diff --git a/server/main_test.go b/server/main_test.go index 6089aa8..cced5f6 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -8,14 +8,18 @@ import ( "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/middleware" ) -func TestHealthEndpoint(t *testing.T) { +func init() { gin.SetMode(gin.TestMode) +} + +func TestHealthEndpoint(t *testing.T) { r := gin.New() r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok", "database": false}) + c.JSON(200, gin.H{"status": "ok", "version": "test", "database": false}) }) w := httptest.NewRecorder() @@ -28,7 +32,6 @@ func TestHealthEndpoint(t *testing.T) { } func TestCORSHeaders(t *testing.T) { - gin.SetMode(gin.TestMode) r := gin.New() r.Use(middleware.CORS()) r.GET("/test", func(c *gin.Context) { @@ -40,28 +43,25 @@ func TestCORSHeaders(t *testing.T) { r.ServeHTTP(w, req) if w.Code != http.StatusNoContent { - t.Errorf("Expected status %d for OPTIONS, got %d", http.StatusNoContent, w.Code) + t.Errorf("Expected %d for OPTIONS, got %d", http.StatusNoContent, w.Code) } - if w.Header().Get("Access-Control-Allow-Origin") != "*" { - t.Error("Missing or incorrect CORS header") + t.Error("Missing CORS header") } } -func TestRouterSetup(t *testing.T) { - gin.SetMode(gin.TestMode) +func TestAuthRoutesRegistered(t *testing.T) { + cfg := &config.Config{JWTSecret: "test-secret"} + auth := handlers.NewAuthHandler(cfg) + r := gin.New() - - r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) - }) - api := r.Group("/api/v1") + authGroup := api.Group("/auth") { - api.POST("/auth/register", handleRegister) - api.POST("/auth/login", handleLogin) - api.GET("/chats", handleListChats) - api.POST("/chats", handleCreateChat) + authGroup.POST("/register", auth.Register) + authGroup.POST("/login", auth.Login) + authGroup.POST("/refresh", auth.Refresh) + authGroup.POST("/logout", auth.Logout) } routes := r.Routes() @@ -70,27 +70,45 @@ func TestRouterSetup(t *testing.T) { routePaths[route.Method+" "+route.Path] = true } - expectedRoutes := []string{ - "GET /health", + expected := []string{ "POST /api/v1/auth/register", "POST /api/v1/auth/login", - "GET /api/v1/chats", - "POST /api/v1/chats", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/logout", } - for _, expected := range expectedRoutes { - if !routePaths[expected] { - t.Errorf("Expected route %s to be registered", expected) + for _, e := range expected { + if !routePaths[e] { + t.Errorf("Expected route %s to be registered", e) } } } -func TestAuthMiddlewareNoDatabase(t *testing.T) { - gin.SetMode(gin.TestMode) +func TestProtectedRoutesRegistered(t *testing.T) { r := gin.New() + api := r.Group("/api/v1") + protected := api.Group("") + { + protected.GET("/chats", handleListChats) + protected.POST("/chats", handleCreateChat) + protected.GET("/chats/:id", handleGetChat) + protected.DELETE("/chats/:id", handleDeleteChat) + protected.GET("/chats/:id/messages", handleGetMessages) + protected.POST("/chats/:id/messages", handleCreateMessage) + protected.GET("/settings", handleGetSettings) + protected.GET("/api-configs", handleListAPIConfigs) + protected.GET("/models", handleListModels) + } + routes := r.Routes() + if len(routes) != 9 { + t.Errorf("Expected 9 routes, got %d", len(routes)) + } +} + +func TestAuthMiddlewareNoDatabase(t *testing.T) { cfg := &config.Config{JWTSecret: "test-secret"} - // No database connected — middleware should pass through + r := gin.New() r.Use(middleware.Auth(cfg)) r.GET("/protected", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) @@ -100,7 +118,22 @@ func TestAuthMiddlewareNoDatabase(t *testing.T) { req, _ := http.NewRequest("GET", "/protected", nil) r.ServeHTTP(w, req) + // No database connected → middleware should pass through if w.Code != http.StatusOK { t.Errorf("Expected pass-through with no DB, got status %d", w.Code) } } + +func TestPlaceholderHandlersReturn501(t *testing.T) { + r := gin.New() + r.GET("/chats", handleListChats) + r.POST("/chats", handleCreateChat) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/chats", nil) + r.ServeHTTP(w, req) + + if w.Code != 501 { + t.Errorf("Expected 501 for unimplemented handler, got %d", w.Code) + } +} diff --git a/server/middleware/auth.go b/server/middleware/auth.go index 7c09f12..56b7eaf 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -11,10 +11,11 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" ) -// Claims represents the JWT payload. +// Claims represents the JWT payload. Must match handlers.Claims. type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` + Role string `json:"role"` jwt.RegisteredClaims } @@ -63,6 +64,22 @@ func Auth(cfg *config.Config) gin.HandlerFunc { // Store claims in context for downstream handlers c.Set("user_id", claims.UserID) c.Set("email", claims.Email) + c.Set("role", claims.Role) c.Next() } -} \ No newline at end of file +} + +// CORS returns a middleware that sets cross-origin headers. +// NOTE: If you have a separate cors.go, delete it — CORS lives here only. +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/server/middleware/cors.go b/server/middleware/cors.go deleted file mode 100644 index aa8b041..0000000 --- a/server/middleware/cors.go +++ /dev/null @@ -1,100 +0,0 @@ -package middleware - -import ( - "github.com/gin-gonic/gin" -) - -// CORSConfig holds the configuration for the CORS middleware -type CORSConfig struct { - AllowedOrigins []string - AllowedMethods []string - AllowedHeaders []string - AllowCredentials bool - MaxAge int -} - -// DefaultCORSConfig returns the default CORS configuration -func DefaultCORSConfig() *CORSConfig { - return &CORSConfig{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, - AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"}, - AllowCredentials: false, - MaxAge: 86400, // 24 hours - } -} - -// CORS returns a Gin middleware handler for CORS (Cross-Origin Resource Sharing) -func CORS() gin.HandlerFunc { - return CORSWithConfig(DefaultCORSConfig()) -} - -// CORSWithConfig returns a Gin middleware handler for CORS with custom configuration -func CORSWithConfig(config *CORSConfig) gin.HandlerFunc { - return func(c *gin.Context) { - origin := c.GetHeader("Origin") - - // Check if the origin is allowed - if len(config.AllowedOrigins) > 0 && config.AllowedOrigins[0] != "*" { - allowed := false - for _, allowedOrigin := range config.AllowedOrigins { - if origin == allowedOrigin { - allowed = true - break - } - } - if !allowed { - c.AbortWithStatusJSON(403, gin.H{ - "error": "origin not allowed", - }) - return - } - } - - // Set CORS headers - if len(config.AllowedOrigins) > 0 { - if config.AllowedOrigins[0] == "*" { - c.Header("Access-Control-Allow-Origin", "*") - } else { - c.Header("Access-Control-Allow-Origin", origin) - } - } - - c.Header("Access-Control-Allow-Methods", joinStringSlice(config.AllowedMethods, ", ")) - c.Header("Access-Control-Allow-Headers", joinStringSlice(config.AllowedHeaders, ", ")) - - if config.AllowCredentials { - c.Header("Access-Control-Allow-Credentials", "true") - } - - c.Header("Access-Control-Max-Age", stringFromInt(config.MaxAge)) - - // Handle preflight requests - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - - c.Next() - } -} - -// Helper function to join a slice of strings -func joinStringSlice(slice []string, sep string) string { - if len(slice) == 0 { - return "" - } - result := slice[0] - for i := 1; i < len(slice); i++ { - result += sep + slice[i] - } - return result -} - -// Helper function to convert int to string -func stringFromInt(n int) string { - if n == 0 { - return "" - } - return string(rune('0'+n%10)) + stringFromInt(n/10) -} \ No newline at end of file diff --git a/server/middleware/ratelimit.go b/server/middleware/ratelimit.go new file mode 100644 index 0000000..3c1a3ef --- /dev/null +++ b/server/middleware/ratelimit.go @@ -0,0 +1,92 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type visitor struct { + tokens float64 + lastSeen time.Time +} + +// RateLimiter implements a per-IP token bucket rate limiter. +type RateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate float64 // tokens per second + burst int // max tokens +} + +// NewRateLimiter creates a rate limiter. +// rate is requests per second, burst is max burst size. +func NewRateLimiter(rate float64, burst int) *RateLimiter { + rl := &RateLimiter{ + visitors: make(map[string]*visitor), + rate: rate, + burst: burst, + } + + // Cleanup stale entries every 5 minutes + go func() { + for { + time.Sleep(5 * time.Minute) + rl.cleanup() + } + }() + + return rl +} + +// Limit returns a Gin middleware that enforces the rate limit. +func (rl *RateLimiter) Limit() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + rl.mu.Lock() + v, exists := rl.visitors[ip] + now := time.Now() + + if !exists { + v = &visitor{tokens: float64(rl.burst), lastSeen: now} + rl.visitors[ip] = v + } + + // Refill tokens based on elapsed time + elapsed := now.Sub(v.lastSeen).Seconds() + v.tokens += elapsed * rl.rate + if v.tokens > float64(rl.burst) { + v.tokens = float64(rl.burst) + } + v.lastSeen = now + + if v.tokens < 1 { + rl.mu.Unlock() + c.Header("Retry-After", "1") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + }) + return + } + + v.tokens-- + rl.mu.Unlock() + + c.Next() + } +} + +func (rl *RateLimiter) cleanup() { + rl.mu.Lock() + defer rl.mu.Unlock() + + cutoff := time.Now().Add(-10 * time.Minute) + for ip, v := range rl.visitors { + if v.lastSeen.Before(cutoff) { + delete(rl.visitors, ip) + } + } +}