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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user