feat(auth,security,scheduling): JWT revocation, S3 fix, notes validation, docs, tests
- JWT revocation with JTI (UUID v4): in-memory tracking, POST /api/logout, refresh handler revokes old JTI, RequireAuth rejects revoked tokens - Fix extractKey for S3 portfolio deletion: extracts full key path from URLs instead of just filename, preventing orphaned storage files - Notes validation: max=1000000 on all 13 Notes fields across 4 booking structs - CharCounter: grapheme-aware counter (Intl.Segmenter), threshold 750K, color-coded, integrated into 6 booking/admin components - loginInProgress: timestamp-based tracking, 30s staleness, 20-entry cap (429), ticker cleanup for stuck entries - Profile picture 15MB client-side limit, portfolio 20MB backend limit - Exceptional scheduling: expand query start to Monday of week - TodayCalendar: week-range fetching, closing time indicator, short-day lunch skip - NavBar: link reorder, mobile burger badge, slide transition, backdrop - ImageUpload: 20MB limit with visual feedback - formatDateISO: shared YYYY-MM-DD utility, shouldApplyLunchProtection helper - Update README.md and all Obsidian docs (Overview, Technical, Admin, Future Work) - Add 28 new tests: JWT (11), auth handlers (7), portfolio extractKey (5), notes validation (5). go build + go vet clean with test,dev tags
This commit is contained in:
+109
-18
@@ -2,7 +2,9 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
@@ -13,46 +15,135 @@ var TokenAuth *jwtauth.JWTAuth
|
||||
// AuthResponse is the response structure for login/refresh endpoints
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
JTI string `json:"jti"`
|
||||
}
|
||||
|
||||
// In-memory revoked JTI tracking
|
||||
var (
|
||||
revokedJTIs = make(map[string]time.Time)
|
||||
revokedJTIsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// generateJTI generates a UUID v4 string using crypto/rand
|
||||
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
func generateJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate JTI: %w", err)
|
||||
}
|
||||
|
||||
// Set version 4 bits
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
// Set variant bits (10xx)
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
// RevokeJTI adds a JTI to the revoked set with its expiry time
|
||||
func RevokeJTI(jti string, expiresAt time.Time) {
|
||||
revokedJTIsMu.Lock()
|
||||
defer revokedJTIsMu.Unlock()
|
||||
revokedJTIs[jti] = expiresAt
|
||||
}
|
||||
|
||||
// IsJTIRevoked checks if a JTI is in the revoked set
|
||||
func IsJTIRevoked(jti string) bool {
|
||||
revokedJTIsMu.RLock()
|
||||
defer revokedJTIsMu.RUnlock()
|
||||
_, revoked := revokedJTIs[jti]
|
||||
return revoked
|
||||
}
|
||||
|
||||
// CleanupRevokedJTIs removes entries where the expiry time has passed
|
||||
func CleanupRevokedJTIs() {
|
||||
revokedJTIsMu.Lock()
|
||||
defer revokedJTIsMu.Unlock()
|
||||
now := time.Now()
|
||||
for jti, expiresAt := range revokedJTIs {
|
||||
if now.After(expiresAt) {
|
||||
delete(revokedJTIs, jti)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
CleanupRevokedJTIs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func InitJWT(secret string) {
|
||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||
}
|
||||
|
||||
// GenerateToken creates a JWT with user_id and role
|
||||
func GenerateToken(userID string, role string) (string, error) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days
|
||||
})
|
||||
return tokenString, err
|
||||
}
|
||||
|
||||
// VerifyToken validates JWT and returns user_id and role
|
||||
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, err error) {
|
||||
token, err := TokenAuth.Decode(tokenString)
|
||||
// GenerateToken creates a JWT with user_id, role, and a unique jti claim
|
||||
// Returns the token string, the JTI, and any error
|
||||
func GenerateToken(userID string, role string) (string, string, error) {
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"jti": jti,
|
||||
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days
|
||||
})
|
||||
return tokenString, jti, err
|
||||
}
|
||||
|
||||
// VerifyToken validates JWT and returns user_id, role, and jti
|
||||
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, jti string, err error) {
|
||||
token, err := TokenAuth.Decode(tokenString)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var uidVal interface{}
|
||||
if err := token.Get("user_id", &uidVal); err != nil {
|
||||
return "", "", fmt.Errorf("invalid user_id claim")
|
||||
return "", "", "", fmt.Errorf("invalid user_id claim")
|
||||
}
|
||||
userID, ok := uidVal.(string)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("invalid user_id claim")
|
||||
return "", "", "", fmt.Errorf("invalid user_id claim")
|
||||
}
|
||||
|
||||
var roleVal interface{}
|
||||
if err := token.Get("role", &roleVal); err != nil {
|
||||
return "", "", fmt.Errorf("invalid role claim")
|
||||
return "", "", "", fmt.Errorf("invalid role claim")
|
||||
}
|
||||
role, ok = roleVal.(string)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("invalid role claim")
|
||||
return "", "", "", fmt.Errorf("invalid role claim")
|
||||
}
|
||||
|
||||
return userID, role, nil
|
||||
var jtiVal interface{}
|
||||
if err := token.Get("jti", &jtiVal); err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid jti claim")
|
||||
}
|
||||
jti, ok = jtiVal.(string)
|
||||
if !ok || jti == "" {
|
||||
return "", "", "", fmt.Errorf("invalid jti claim")
|
||||
}
|
||||
|
||||
if IsJTIRevoked(jti) {
|
||||
return "", "", "", fmt.Errorf("token revoked")
|
||||
}
|
||||
jti, ok = jtiVal.(string)
|
||||
if !ok || jti == "" {
|
||||
return "", "", "", fmt.Errorf("invalid jti claim")
|
||||
}
|
||||
|
||||
if IsJTIRevoked(jti) {
|
||||
return "", "", "", fmt.Errorf("token revoked")
|
||||
}
|
||||
|
||||
return userID, role, jti, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package auth
|
||||
|
||||
// Package auth contains tests for JWT generation, verification, and JTI revocation.
|
||||
//
|
||||
// Test Coverage:
|
||||
// - generateJTI: UUID v4 format validation, uniqueness
|
||||
// - GenerateToken: returns non-empty token and JTI, JTI matches claim
|
||||
// - VerifyToken: returns correct user_id/role/JTI, rejects revoked/missing JTI
|
||||
// - RevokeJTI: adds to revoked set, IsJTIRevoked reflects changes
|
||||
// - CleanupRevokedJTIs: removes expired JTIs, keeps valid ones
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
InitJWT("test-secret-key-for-jwt-test")
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// generateJTI Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestGenerateJTI_Format verifies that generateJTI returns a valid UUID v4 string
|
||||
// in the format: 8-4-4-4-12 hexadecimal digits with version and variant bits set.
|
||||
func TestGenerateJTI_Format(t *testing.T) {
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
t.Fatalf("generateJTI() failed: %v", err)
|
||||
}
|
||||
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
parts := strings.Split(jti, "-")
|
||||
if len(parts) != 5 {
|
||||
t.Errorf("expected 5 parts in UUID, got %d: %s", len(parts), jti)
|
||||
}
|
||||
|
||||
// Check each part length: 8-4-4-4-12
|
||||
expectedLengths := []int{8, 4, 4, 4, 12}
|
||||
for i, part := range parts {
|
||||
if len(part) != expectedLengths[i] {
|
||||
t.Errorf("part %d expected length %d, got %d (jti=%s)", i, expectedLengths[i], len(part), jti)
|
||||
}
|
||||
}
|
||||
|
||||
// Check version nibble (4xxx) in the third group
|
||||
if len(parts[2]) > 0 && parts[2][0] != '4' {
|
||||
t.Errorf("expected version 4 UUID, got version %c in jti=%s", parts[2][0], jti)
|
||||
}
|
||||
|
||||
// Check variant bits (8xxx, 9xxx, axxx, or bxxx) in the fourth group
|
||||
if len(parts[3]) > 0 {
|
||||
c := parts[3][0]
|
||||
if c != '8' && c != '9' && c != 'a' && c != 'b' {
|
||||
t.Errorf("expected variant bits 10xx, got %c in jti=%s", c, jti)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateJTI_Unique generates 100 JTIs and verifies all are unique.
|
||||
func TestGenerateJTI_Unique(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
jti, err := generateJTI()
|
||||
if err != nil {
|
||||
t.Fatalf("generateJTI() failed at iteration %d: %v", i, err)
|
||||
}
|
||||
if seen[jti] {
|
||||
t.Errorf("duplicate JTI generated at iteration %d: %s", i, jti)
|
||||
}
|
||||
seen[jti] = true
|
||||
}
|
||||
|
||||
if len(seen) != 100 {
|
||||
t.Errorf("expected 100 unique JTIs, got %d", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GenerateToken Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestGenerateToken_ReturnsJTI verifies that GenerateToken returns both a
|
||||
// non-empty token string and a non-empty JTI string.
|
||||
func TestGenerateToken_ReturnsJTI(t *testing.T) {
|
||||
token, jti, err := GenerateToken("user-001", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
if jti == "" {
|
||||
t.Error("expected non-empty JTI")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateToken_JTIInClaims decodes the generated token and verifies
|
||||
// the "jti" claim matches the returned JTI value.
|
||||
func TestGenerateToken_JTIInClaims(t *testing.T) {
|
||||
token, jti, err := GenerateToken("user-002", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := TokenAuth.Decode(token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode token: %v", err)
|
||||
}
|
||||
|
||||
var claimJTI string
|
||||
if err := decoded.Get("jti", &claimJTI); err != nil {
|
||||
t.Fatalf("failed to get jti claim: %v", err)
|
||||
}
|
||||
|
||||
if claimJTI != jti {
|
||||
t.Errorf("expected jti claim %q, got %q", jti, claimJTI)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VerifyToken Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestVerifyToken_ReturnsJTI creates a token, verifies it, and checks the
|
||||
// returned user_id, role, and JTI match the expected values.
|
||||
func TestVerifyToken_ReturnsJTI(t *testing.T) {
|
||||
token, jti, err := GenerateToken("user-003", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
userID, role, returnedJTI, err := VerifyToken(token, context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyToken() failed: %v", err)
|
||||
}
|
||||
|
||||
if userID != "user-003" {
|
||||
t.Errorf("expected userID 'user-003', got %q", userID)
|
||||
}
|
||||
if role != "verified_email" {
|
||||
t.Errorf("expected role 'verified_email', got %q", role)
|
||||
}
|
||||
if returnedJTI != jti {
|
||||
t.Errorf("expected JTI %q, got %q", jti, returnedJTI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_RevokedJTI creates a token, revokes its JTI, and verifies
|
||||
// that VerifyToken returns an error containing "token revoked".
|
||||
func TestVerifyToken_RevokedJTI(t *testing.T) {
|
||||
token, jti, err := GenerateToken("user-004", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
|
||||
|
||||
_, _, _, err = VerifyToken(token, context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for revoked JTI, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoked") {
|
||||
t.Errorf("expected 'token revoked' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyToken_MissingJTI creates a token without a "jti" claim (using
|
||||
// TokenAuth.Encode directly) and verifies that VerifyToken returns an error
|
||||
// containing "invalid jti claim".
|
||||
func TestVerifyToken_MissingJTI(t *testing.T) {
|
||||
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
||||
"user_id": "user-005",
|
||||
"role": "verified_email",
|
||||
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create token without JTI: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, err = VerifyToken(tokenString, context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing JTI, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jti claim") {
|
||||
t.Errorf("expected 'invalid jti claim' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RevokeJTI / IsJTIRevoked Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestRevokeJTI_AddsToSet verifies that calling RevokeJTI adds the JTI to the
|
||||
// revoked set, and IsJTIRevoked returns true for it.
|
||||
func TestRevokeJTI_AddsToSet(t *testing.T) {
|
||||
_, jti, err := GenerateToken("user-006", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
if IsJTIRevoked(jti) {
|
||||
t.Fatal("JTI should not be revoked before calling RevokeJTI")
|
||||
}
|
||||
|
||||
RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
|
||||
|
||||
if !IsJTIRevoked(jti) {
|
||||
t.Error("expected IsJTIRevoked to return true after RevokeJTI")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsJTIRevoked_NonExistent verifies that checking a non-existent JTI
|
||||
// returns false.
|
||||
func TestIsJTIRevoked_NonExistent(t *testing.T) {
|
||||
if IsJTIRevoked("nonexistent-jti-12345") {
|
||||
t.Error("expected IsJTIRevoked to return false for non-existent JTI")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CleanupRevokedJTIs Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestCleanupRevokedJTIs_RemovesExpired adds a JTI with a past expiry time,
|
||||
// runs CleanupRevokedJTIs, and verifies the JTI is removed from the set.
|
||||
func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) {
|
||||
_, jti, err := GenerateToken("user-007", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
// Add with past expiry (1 hour ago)
|
||||
RevokeJTI(jti, time.Now().Add(-1*time.Hour))
|
||||
|
||||
if !IsJTIRevoked(jti) {
|
||||
t.Fatal("JTI should be in revoked set before cleanup")
|
||||
}
|
||||
|
||||
CleanupRevokedJTIs()
|
||||
|
||||
if IsJTIRevoked(jti) {
|
||||
t.Error("expected expired JTI to be removed after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupRevokedJTIs_KeepsValid adds a JTI with a future expiry time,
|
||||
// runs CleanupRevokedJTIs, and verifies the JTI is still in the set.
|
||||
func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) {
|
||||
_, jti, err := GenerateToken("user-008", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() failed: %v", err)
|
||||
}
|
||||
|
||||
// Add with future expiry
|
||||
RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
|
||||
|
||||
if !IsJTIRevoked(jti) {
|
||||
t.Fatal("JTI should be in revoked set before cleanup")
|
||||
}
|
||||
|
||||
CleanupRevokedJTIs()
|
||||
|
||||
if !IsJTIRevoked(jti) {
|
||||
t.Error("expected valid (future expiry) JTI to remain after cleanup")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/auth"
|
||||
"crussell/db"
|
||||
"crussell/internal/dav"
|
||||
"crussell/mw"
|
||||
@@ -38,6 +39,7 @@ import (
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -1105,5 +1107,291 @@ func TestRegister_WithInvalidReferralCodeFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Logout Handler Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestLogoutHandler_Success verifies that a valid logout request returns
|
||||
// 200 OK with {"success": true} and revokes the JTI.
|
||||
func TestLogoutHandler_Success(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
// Create a test user
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Generate a token and get its JTI
|
||||
token, jti, err := auth.GenerateToken(userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// Create request with JTI in context (simulating RequireAuth middleware)
|
||||
req := httptest.NewRequest("POST", "/api/logout", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
ctx := context.WithValue(req.Context(), mw.JTIKey, jti)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LogoutHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Error("expected success=true in response")
|
||||
}
|
||||
|
||||
// Verify JTI was revoked
|
||||
if !auth.IsJTIRevoked(jti) {
|
||||
t.Error("expected JTI to be revoked after logout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutHandler_RevokesJTI verifies that after logout, the token's JTI is
|
||||
// revoked and the token can no longer be used with authenticated endpoints.
|
||||
func TestLogoutHandler_RevokesJTI(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
// Create a test user
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Generate a token and get its JTI
|
||||
token, jti, err := auth.GenerateToken(userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// Call logout with JTI in context
|
||||
req := httptest.NewRequest("POST", "/api/logout", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
ctx := context.WithValue(req.Context(), mw.JTIKey, jti)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
LogoutHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("logout failed: %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Try to use the same token through RequireAuth middleware
|
||||
router := chi.NewRouter()
|
||||
router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/api/protected", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+token)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for revoked token, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutHandler_NoToken verifies that calling logout without an
|
||||
// Authorization header returns 401.
|
||||
func TestLogoutHandler_NoToken(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/logout", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Without JTI in context, handler returns 401
|
||||
LogoutHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutHandler_InvalidToken verifies that calling logout with an empty
|
||||
// JTI returns 401.
|
||||
func TestLogoutHandler_InvalidToken(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/logout", nil)
|
||||
ctx := context.WithValue(req.Context(), mw.JTIKey, "")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LogoutHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Refresh Token JTI Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestRefreshToken_RevokesOldJTI verifies that refreshing a token revokes the
|
||||
// old JTI and issues a new one. The old token becomes invalid after refresh.
|
||||
func TestRefreshToken_RevokesOldJTI(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
// Create a test user
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Generate initial token and JTI
|
||||
oldToken, oldJTI, err := auth.GenerateToken(userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate old token: %v", err)
|
||||
}
|
||||
|
||||
// Verify old JTI is not yet revoked
|
||||
if auth.IsJTIRevoked(oldJTI) {
|
||||
t.Fatal("old JTI should not be revoked before refresh")
|
||||
}
|
||||
|
||||
// Call refresh handler with old JTI in context
|
||||
req := httptest.NewRequest("POST", "/api/refresh-token", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+oldToken)
|
||||
ctx := req.Context()
|
||||
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, mw.UserRoleKey, "verified_email")
|
||||
ctx = context.WithValue(ctx, mw.JTIKey, oldJTI)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
RefreshTokenHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify old JTI was revoked
|
||||
if !auth.IsJTIRevoked(oldJTI) {
|
||||
t.Error("expected old JTI to be revoked after refresh")
|
||||
}
|
||||
|
||||
// Verify old token is rejected by middleware
|
||||
router := chi.NewRouter()
|
||||
router.With(mw.RequireAuth).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/api/protected", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+oldToken)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for revoked token after refresh, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Login Response JTI Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestLoginResponse_IncludesJTI verifies that the login response includes both
|
||||
// "token" and "jti" fields, both non-empty.
|
||||
func TestLoginResponse_IncludesJTI(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
// Create a test user with known email
|
||||
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "jti-test@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "jti-test@test.com",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
|
||||
w := makeRequest(handler, "POST", "/api/login", body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
JTI string `json:"jti"`
|
||||
}
|
||||
if err := parseResponseBody(w, &resp); err != nil {
|
||||
t.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Token == "" {
|
||||
t.Error("expected non-empty token in login response")
|
||||
}
|
||||
if resp.JTI == "" {
|
||||
t.Error("expected non-empty jti in login response")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LoginInProgress Rate Limiting Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestLoginInProgress_Cap verifies that when the loginInProgress map is full
|
||||
// (20 concurrent logins), the 21st attempt returns HTTP 429 Too Many Requests.
|
||||
func TestLoginInProgress_Cap(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
// Create a test user
|
||||
userID, err := fixtures.CreateTestUserWithEmail(db.DB, "ratelimit@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Fill the loginInProgress map with 20 entries
|
||||
loginStateMu.Lock()
|
||||
for i := 0; i < maxLoginInProgress; i++ {
|
||||
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = time.Now()
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
// Clean up
|
||||
loginStateMu.Lock()
|
||||
for i := 0; i < maxLoginInProgress; i++ {
|
||||
delete(loginInProgress, fmt.Sprintf("stale-user-%d", i))
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
}()
|
||||
|
||||
// Attempt login - should get 429
|
||||
body := LoginRequest{
|
||||
Email: "ratelimit@test.com",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
|
||||
w := makeRequest(handler, "POST", "/api/login", body)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected status 429, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure test compilation - import pgxpool to avoid unused import
|
||||
var _ = func() *pgxpool.Pool { return nil }
|
||||
|
||||
@@ -30,10 +30,12 @@ var (
|
||||
titleCaser = cases.Title(language.English)
|
||||
)
|
||||
|
||||
const maxLoginInProgress = 20
|
||||
|
||||
// Login state management
|
||||
var (
|
||||
loginStateMu sync.Mutex
|
||||
loginInProgress = make(map[string]bool)
|
||||
loginInProgress = make(map[string]time.Time)
|
||||
loginAttempts = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
@@ -51,6 +53,12 @@ func init() {
|
||||
delete(loginAttempts, userID)
|
||||
}
|
||||
}
|
||||
// Clean up stuck loginInProgress entries (older than 30s)
|
||||
for userID, startedAt := range loginInProgress {
|
||||
if now.Sub(startedAt) > 30*time.Second {
|
||||
delete(loginInProgress, userID)
|
||||
}
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
}
|
||||
}()
|
||||
@@ -311,12 +319,18 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Check if user is already logging in
|
||||
loginStateMu.Lock()
|
||||
if loginInProgress[userID] {
|
||||
if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second {
|
||||
loginStateMu.Unlock()
|
||||
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
||||
return
|
||||
}
|
||||
loginInProgress[userID] = true
|
||||
// Cap the map size - drop new request if at capacity
|
||||
if len(loginInProgress) >= maxLoginInProgress {
|
||||
loginStateMu.Unlock()
|
||||
http.Error(w, "server busy, try again later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
loginInProgress[userID] = time.Now()
|
||||
loginStateMu.Unlock()
|
||||
|
||||
// Always clear flag when done
|
||||
@@ -363,20 +377,21 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
tokenString, err := auth.GenerateToken(userID, role)
|
||||
tokenString, jti, err := auth.GenerateToken(userID, role)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString})
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString, JTI: jti})
|
||||
}
|
||||
|
||||
// POST /api/refresh-token (requires auth middleware)
|
||||
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := mw.GetUserID(r.Context())
|
||||
role, _ := mw.GetUserRole(r.Context())
|
||||
oldJTI, _ := mw.GetJTI(r.Context())
|
||||
|
||||
// Verify user still exists and role hasn't changed
|
||||
var currentRole string
|
||||
@@ -395,15 +410,36 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the old token's JTI before issuing a new one
|
||||
if oldJTI != "" {
|
||||
// Use a 30-day expiry from now for the revoked JTI (matching token lifetime)
|
||||
auth.RevokeJTI(oldJTI, time.Now().Add(30*24*time.Hour))
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
newToken, err := auth.GenerateToken(userID, currentRole)
|
||||
newToken, jti, err := auth.GenerateToken(userID, currentRole)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken})
|
||||
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken, JTI: jti})
|
||||
}
|
||||
|
||||
// POST /api/logout (requires auth middleware)
|
||||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
jti, ok := mw.GetJTI(r.Context())
|
||||
if !ok || jti == "" {
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the JTI — it will be kept until the token's natural expiry (30 days)
|
||||
auth.RevokeJTI(jti, time.Now().Add(30*24*time.Hour))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
type VerificationCodeRequest struct {
|
||||
|
||||
@@ -33,7 +33,7 @@ type Booking struct {
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Status string `json:"status"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
@@ -111,7 +111,7 @@ type Payment struct {
|
||||
type CreateBookingRequest struct {
|
||||
StartTime time.Time `json:"start_time" validate:"required"`
|
||||
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ type ProgressBookingRequest struct {
|
||||
// ConfirmBookingRequest represents the request payload for confirming a booking
|
||||
type ConfirmBookingRequest struct {
|
||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
}
|
||||
|
||||
// ServiceOverride represents override values for a specific service in a booking
|
||||
@@ -142,7 +142,7 @@ type ServiceOverride struct {
|
||||
type UpdateBookingServicesRequest struct {
|
||||
ServiceIDs []string `json:"service_ids"`
|
||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
}
|
||||
|
||||
// DeleteBookingRequest represents the request payload for deleting a booking with payment
|
||||
@@ -155,7 +155,7 @@ type DeleteBookingRequest struct {
|
||||
type AdminUserSummary struct {
|
||||
FullName string `json:"full_name"`
|
||||
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
}
|
||||
|
||||
// AdminBookingSummary represents a complete booking summary for admin view
|
||||
@@ -184,7 +184,7 @@ type UserSummary struct {
|
||||
ReferralCode *string `json:"referral_code,omitempty"`
|
||||
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
}
|
||||
|
||||
type BookingServiceDetail struct {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/user"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
@@ -2237,6 +2238,89 @@ func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notes Validation Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestNotesValidation_UnderLimit verifies that a Notes string of 999,999
|
||||
// characters passes go-playground/validator validation (max=1000000).
|
||||
func TestNotesValidation_UnderLimit(t *testing.T) {
|
||||
longNotes := strings.Repeat("a", 999999)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: time.Now().Add(72 * time.Hour),
|
||||
ServiceIDs: []string{"test-service-id"},
|
||||
Notes: &longNotes,
|
||||
}
|
||||
|
||||
err := validators.Validate.Struct(&req)
|
||||
if err != nil {
|
||||
t.Errorf("expected validation to pass for 999999-char notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesValidation_AtLimit verifies that a Notes string of exactly
|
||||
// 1,000,000 characters passes validation (max=1000000, boundary test).
|
||||
func TestNotesValidation_AtLimit(t *testing.T) {
|
||||
exactNotes := strings.Repeat("b", 1000000)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: time.Now().Add(72 * time.Hour),
|
||||
ServiceIDs: []string{"test-service-id"},
|
||||
Notes: &exactNotes,
|
||||
}
|
||||
|
||||
err := validators.Validate.Struct(&req)
|
||||
if err != nil {
|
||||
t.Errorf("expected validation to pass for 1000000-char notes (boundary), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesValidation_OverLimit verifies that a Notes string of 1,000,001
|
||||
// characters fails validation with max=1000000.
|
||||
func TestNotesValidation_OverLimit(t *testing.T) {
|
||||
tooLongNotes := strings.Repeat("c", 1000001)
|
||||
req := CreateBookingRequest{
|
||||
StartTime: time.Now().Add(72 * time.Hour),
|
||||
ServiceIDs: []string{"test-service-id"},
|
||||
Notes: &tooLongNotes,
|
||||
}
|
||||
|
||||
err := validators.Validate.Struct(&req)
|
||||
if err == nil {
|
||||
t.Error("expected validation to fail for 1000001-char notes (over limit)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesValidation_NilPointer verifies that a nil Notes pointer passes
|
||||
// validation (omitempty tag allows nil/empty).
|
||||
func TestNotesValidation_NilPointer(t *testing.T) {
|
||||
req := CreateBookingRequest{
|
||||
StartTime: time.Now().Add(72 * time.Hour),
|
||||
ServiceIDs: []string{"test-service-id"},
|
||||
Notes: nil,
|
||||
}
|
||||
|
||||
err := validators.Validate.Struct(&req)
|
||||
if err != nil {
|
||||
t.Errorf("expected validation to pass for nil Notes (omitempty), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesValidation_EmptyString verifies that an empty string Notes passes
|
||||
// validation (omitempty and max=1000000 allows empty strings).
|
||||
func TestNotesValidation_EmptyString(t *testing.T) {
|
||||
emptyNotes := ""
|
||||
req := CreateBookingRequest{
|
||||
StartTime: time.Now().Add(72 * time.Hour),
|
||||
ServiceIDs: []string{"test-service-id"},
|
||||
Notes: &emptyNotes,
|
||||
}
|
||||
|
||||
err := validators.Validate.Struct(&req)
|
||||
if err != nil {
|
||||
t.Errorf("expected validation to pass for empty string Notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure test compilation - import pgxpool to avoid unused import
|
||||
var _ = func() *pgxpool.Pool { return nil }
|
||||
|
||||
|
||||
@@ -422,7 +422,7 @@ type AdminCreateBookingForUserRequest struct {
|
||||
StartTime time.Time `json:"start_time" validate:"required"`
|
||||
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
||||
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // appointment notes, visible to customers and staff
|
||||
EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks
|
||||
}
|
||||
|
||||
@@ -860,7 +860,7 @@ type BookingEditRequest struct {
|
||||
RequestedBy string `json:"requested_by"`
|
||||
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
||||
NewServices []string `json:"new_services"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
HasOverrides bool `json:"has_overrides"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Joined fields
|
||||
@@ -880,7 +880,7 @@ type EditSnapshot struct {
|
||||
StartTime *time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
Services []EditServiceDetail `json:"services"`
|
||||
Notes *string `json:"notes"`
|
||||
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
|
||||
}
|
||||
|
||||
type EditUserSummary struct {
|
||||
@@ -895,7 +895,7 @@ type EnrichedEditRequest struct {
|
||||
BookingID string `json:"booking_id"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Notes *string `json:"notes"`
|
||||
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
|
||||
Original *EditSnapshot `json:"original"`
|
||||
Proposed *EditSnapshot `json:"proposed"`
|
||||
User *EditUserSummary `json:"user,omitempty"`
|
||||
@@ -1170,7 +1170,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
NewStartTime *time.Time `json:"new_start_time,omitempty"`
|
||||
NewServices []string `json:"new_services"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
|
||||
@@ -69,7 +69,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
var err error
|
||||
userID, _, err = auth.VerifyToken(tokenString, r.Context())
|
||||
userID, _, _, err = auth.VerifyToken(tokenString, r.Context())
|
||||
if err != nil {
|
||||
// Invalid token - treat as unauthenticated
|
||||
userID = ""
|
||||
|
||||
@@ -733,11 +733,27 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func extractKey(url string) string {
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
// URL format: https://endpoint/bucket/portfolio/1234567890.jpg
|
||||
// Need to return: portfolio/1234567890.jpg
|
||||
// Find the bucket segment: skip past scheme://endpoint/
|
||||
idx := strings.Index(url, "://")
|
||||
if idx == -1 {
|
||||
return url
|
||||
}
|
||||
return url
|
||||
rest := url[idx+3:] // skip "://"
|
||||
// Now rest = "endpoint/bucket/portfolio/1234567890.jpg"
|
||||
// Skip first path segment (endpoint)
|
||||
slashIdx := strings.Index(rest, "/")
|
||||
if slashIdx == -1 {
|
||||
return rest
|
||||
}
|
||||
rest = rest[slashIdx+1:] // "bucket/portfolio/1234567890.jpg"
|
||||
// Skip second path segment (bucket)
|
||||
slashIdx = strings.Index(rest, "/")
|
||||
if slashIdx == -1 {
|
||||
return rest
|
||||
}
|
||||
return rest[slashIdx+1:] // "portfolio/1234567890.jpg"
|
||||
}
|
||||
|
||||
func GetImage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -559,6 +559,65 @@ func TestPortfolio_ProcessImage_EXIFStripped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// extractKey Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestExtractKey_FullPath tests extractKey with a full S3/R2 URL, verifying
|
||||
// it extracts the key after bucket: "portfolio/1234567890.jpg".
|
||||
func TestExtractKey_FullPath(t *testing.T) {
|
||||
url := "https://endpoint.example.com/crussell/portfolio/1234567890.jpg"
|
||||
expected := "portfolio/1234567890.jpg"
|
||||
result := extractKey(url)
|
||||
if result != expected {
|
||||
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractKey_NestedPath tests extractKey with a nested path (thumbnail
|
||||
// subdirectory), verifying it extracts the full key including subdirectories.
|
||||
func TestExtractKey_NestedPath(t *testing.T) {
|
||||
url := "https://endpoint.example.com/crussell/portfolio/thumbs/1234567890.jpg"
|
||||
expected := "portfolio/thumbs/1234567890.jpg"
|
||||
result := extractKey(url)
|
||||
if result != expected {
|
||||
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractKey_NoScheme tests extractKey with a URL missing the scheme,
|
||||
// which exercises the fallback path (no "://" found → returns url as-is).
|
||||
func TestExtractKey_NoScheme(t *testing.T) {
|
||||
url := "endpoint.example.com/crussell/portfolio/1234567890.jpg"
|
||||
expected := "endpoint.example.com/crussell/portfolio/1234567890.jpg"
|
||||
result := extractKey(url)
|
||||
if result != expected {
|
||||
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractKey_PlainFilename tests extractKey with just a filename (no
|
||||
// URL structure at all), verifying it returns the input unchanged.
|
||||
func TestExtractKey_PlainFilename(t *testing.T) {
|
||||
url := "1234567890.jpg"
|
||||
expected := "1234567890.jpg"
|
||||
result := extractKey(url)
|
||||
if result != expected {
|
||||
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractKey_EmptyString tests extractKey with an empty string,
|
||||
// verifying it returns an empty string.
|
||||
func TestExtractKey_EmptyString(t *testing.T) {
|
||||
url := ""
|
||||
expected := ""
|
||||
result := extractKey(url)
|
||||
if result != expected {
|
||||
t.Errorf("extractKey(%q) = %q, want %q", url, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker
|
||||
func createJpegWithExifMarker(jpegData []byte) []byte {
|
||||
if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
|
||||
|
||||
@@ -233,7 +233,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
var err error
|
||||
userID, role, err = auth.VerifyToken(tokenString, r.Context())
|
||||
userID, role, _, err = auth.VerifyToken(tokenString, r.Context())
|
||||
if err != nil {
|
||||
// Invalid token - treat as unauthenticated
|
||||
userID = ""
|
||||
|
||||
@@ -29,7 +29,7 @@ type AppointmentInfo struct {
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Status string `json:"status"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
User *UserInfo `json:"user,omitempty"`
|
||||
Services []ServiceInfo `json:"services"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
|
||||
@@ -75,7 +75,7 @@ type AdminUserDetail struct {
|
||||
LastLoginAt *string `json:"lastLoginAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||
|
||||
// GDPR consent fields
|
||||
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
|
||||
|
||||
+7
-3
@@ -54,8 +54,9 @@ func limitBody(limit int64) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
|
||||
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
|
||||
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
|
||||
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
|
||||
portfolioBodyLimit int64 = 20 * 1024 * 1024 // 20MB
|
||||
)
|
||||
|
||||
func initDB() {
|
||||
@@ -164,6 +165,9 @@ func main() {
|
||||
// Login: Has its own internal rate limiting
|
||||
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/login", authHandlers.LoginHandler)
|
||||
|
||||
// Logout: requires valid token
|
||||
r.With(mw.RequireAuth).Post("/logout", authHandlers.LogoutHandler)
|
||||
|
||||
// Email verification
|
||||
r.With(mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
|
||||
r.With(mw.RateLimit(20, time.Minute), limitBody(defaultBodyLimit)).Post("/verify/check", authHandlers.VerifyCodeHandler)
|
||||
@@ -185,7 +189,7 @@ func main() {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireAdmin)
|
||||
r.Use(mw.RateLimit(60, time.Minute))
|
||||
r.With(limitBody(uploadBodyLimit)).Post("/images", portfolio.UploadImage)
|
||||
r.With(limitBody(portfolioBodyLimit)).Post("/images", portfolio.UploadImage)
|
||||
r.Delete("/images/{id}", portfolio.DeleteImage)
|
||||
})
|
||||
})
|
||||
|
||||
+11
-3
@@ -13,6 +13,7 @@ type contextKey string
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserRoleKey contextKey = "user_role"
|
||||
JTIKey contextKey = "jti"
|
||||
)
|
||||
|
||||
// RequireAuth middleware - validates JWT and adds user info to context
|
||||
@@ -26,15 +27,16 @@ func RequireAuth(next http.Handler) http.Handler {
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
userID, role, err := auth.VerifyToken(tokenString, r.Context())
|
||||
userID, role, jti, err := auth.VerifyToken(tokenString, r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Add user info to context
|
||||
// Add user info and JTI to context
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserRoleKey, role)
|
||||
ctx = context.WithValue(ctx, JTIKey, jti)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
@@ -47,10 +49,11 @@ func OptionalAuth(next http.Handler) http.Handler {
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
userID, role, err := auth.VerifyToken(tokenString, r.Context())
|
||||
userID, role, jti, err := auth.VerifyToken(tokenString, r.Context())
|
||||
if err == nil {
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserRoleKey, role)
|
||||
ctx = context.WithValue(ctx, JTIKey, jti)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
@@ -109,3 +112,8 @@ func GetUserRole(ctx context.Context) (string, bool) {
|
||||
role, ok := ctx.Value(UserRoleKey).(string)
|
||||
return role, ok
|
||||
}
|
||||
|
||||
func GetJTI(ctx context.Context) (string, bool) {
|
||||
jti, ok := ctx.Value(JTIKey).(string)
|
||||
return jti, ok
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func EnsureInitialized() {
|
||||
|
||||
func GenerateTestToken(userID, role string) string {
|
||||
EnsureInitialized()
|
||||
token, err := auth.GenerateToken(userID, role)
|
||||
token, _, err := auth.GenerateToken(userID, role)
|
||||
if err != nil {
|
||||
panic("failed to generate test token: " + err.Error())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user