Issue 4 jwt (#24)
This commit is contained in:
20
migrations/002_refresh_tokens.sql
Normal file
20
migrations/002_refresh_tokens.sql
Normal file
@@ -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)
|
||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
golang.org/x/crypto v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -28,7 +29,6 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
golang.org/x/arch v0.3.0 // 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/net v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
golang.org/x/text v0.13.0 // indirect
|
golang.org/x/text v0.13.0 // indirect
|
||||||
|
|||||||
323
server/handlers/auth.go
Normal file
323
server/handlers/auth.go
Normal file
@@ -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[:])
|
||||||
|
}
|
||||||
331
server/handlers/auth_test.go
Normal file
331
server/handlers/auth_test.go
Normal file
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,41 +7,47 @@ import (
|
|||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Load configuration from environment
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// Connect to database (optional — runs without it in unmanaged mode)
|
|
||||||
if err := database.Connect(cfg); err != nil {
|
if err := database.Connect(cfg); err != nil {
|
||||||
log.Printf("⚠ Database unavailable: %v", err)
|
log.Printf("⚠ Database unavailable: %v", err)
|
||||||
log.Println(" Running in unmanaged mode (no persistence)")
|
log.Println(" Running in unmanaged mode (no persistence)")
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
// Initialize router
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
status := gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
"version": Version,
|
||||||
"database": database.IsConnected(),
|
"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")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// Public auth routes
|
authGroup := api.Group("/auth")
|
||||||
api.POST("/auth/register", handleRegister)
|
authGroup.Use(authLimiter.Limit())
|
||||||
api.POST("/auth/login", handleLogin)
|
{
|
||||||
|
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 := api.Group("")
|
||||||
protected.Use(middleware.Auth(cfg))
|
protected.Use(middleware.Auth(cfg))
|
||||||
{
|
{
|
||||||
@@ -65,30 +71,32 @@ func main() {
|
|||||||
protected.POST("/api-configs", handleCreateAPIConfig)
|
protected.POST("/api-configs", handleCreateAPIConfig)
|
||||||
protected.DELETE("/api-configs/:id", handleDeleteAPIConfig)
|
protected.DELETE("/api-configs/:id", handleDeleteAPIConfig)
|
||||||
|
|
||||||
// Models (proxy to configured API)
|
// Models
|
||||||
protected.GET("/models", handleListModels)
|
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 {
|
if err := r.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder handlers — will be moved to handlers/ package
|
// Placeholder handlers — ticket #9
|
||||||
func handleRegister(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 handleLogin(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 handleListChats(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 handleCreateChat(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 handleGetChat(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 handleUpdateChat(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 handleDeleteChat(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 handleGetMessages(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 handleCreateMessage(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 handleGetSettings(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 handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
func handleCreateAPIConfig(c *gin.Context) {
|
||||||
func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
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 handleDeleteAPIConfig(c *gin.Context) {
|
||||||
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
c.JSON(501, gin.H{"error": "not implemented"})
|
||||||
|
}
|
||||||
|
func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthEndpoint(t *testing.T) {
|
func init() {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthEndpoint(t *testing.T) {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.GET("/health", func(c *gin.Context) {
|
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()
|
w := httptest.NewRecorder()
|
||||||
@@ -28,7 +32,6 @@ func TestHealthEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCORSHeaders(t *testing.T) {
|
func TestCORSHeaders(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
r.GET("/test", func(c *gin.Context) {
|
r.GET("/test", func(c *gin.Context) {
|
||||||
@@ -40,28 +43,25 @@ func TestCORSHeaders(t *testing.T) {
|
|||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusNoContent {
|
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") != "*" {
|
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) {
|
func TestAuthRoutesRegistered(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
cfg := &config.Config{JWTSecret: "test-secret"}
|
||||||
|
auth := handlers.NewAuthHandler(cfg)
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
|
authGroup := api.Group("/auth")
|
||||||
{
|
{
|
||||||
api.POST("/auth/register", handleRegister)
|
authGroup.POST("/register", auth.Register)
|
||||||
api.POST("/auth/login", handleLogin)
|
authGroup.POST("/login", auth.Login)
|
||||||
api.GET("/chats", handleListChats)
|
authGroup.POST("/refresh", auth.Refresh)
|
||||||
api.POST("/chats", handleCreateChat)
|
authGroup.POST("/logout", auth.Logout)
|
||||||
}
|
}
|
||||||
|
|
||||||
routes := r.Routes()
|
routes := r.Routes()
|
||||||
@@ -70,27 +70,45 @@ func TestRouterSetup(t *testing.T) {
|
|||||||
routePaths[route.Method+" "+route.Path] = true
|
routePaths[route.Method+" "+route.Path] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedRoutes := []string{
|
expected := []string{
|
||||||
"GET /health",
|
|
||||||
"POST /api/v1/auth/register",
|
"POST /api/v1/auth/register",
|
||||||
"POST /api/v1/auth/login",
|
"POST /api/v1/auth/login",
|
||||||
"GET /api/v1/chats",
|
"POST /api/v1/auth/refresh",
|
||||||
"POST /api/v1/chats",
|
"POST /api/v1/auth/logout",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, expected := range expectedRoutes {
|
for _, e := range expected {
|
||||||
if !routePaths[expected] {
|
if !routePaths[e] {
|
||||||
t.Errorf("Expected route %s to be registered", expected)
|
t.Errorf("Expected route %s to be registered", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthMiddlewareNoDatabase(t *testing.T) {
|
func TestProtectedRoutesRegistered(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
r := gin.New()
|
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"}
|
cfg := &config.Config{JWTSecret: "test-secret"}
|
||||||
// No database connected — middleware should pass through
|
r := gin.New()
|
||||||
r.Use(middleware.Auth(cfg))
|
r.Use(middleware.Auth(cfg))
|
||||||
r.GET("/protected", func(c *gin.Context) {
|
r.GET("/protected", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"ok": true})
|
c.JSON(200, gin.H{"ok": true})
|
||||||
@@ -100,7 +118,22 @@ func TestAuthMiddlewareNoDatabase(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// No database connected → middleware should pass through
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Errorf("Expected pass-through with no DB, got status %d", w.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claims represents the JWT payload.
|
// Claims represents the JWT payload. Must match handlers.Claims.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,22 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
|
|||||||
// Store claims in context for downstream handlers
|
// Store claims in context for downstream handlers
|
||||||
c.Set("user_id", claims.UserID)
|
c.Set("user_id", claims.UserID)
|
||||||
c.Set("email", claims.Email)
|
c.Set("email", claims.Email)
|
||||||
|
c.Set("role", claims.Role)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
92
server/middleware/ratelimit.go
Normal file
92
server/middleware/ratelimit.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user