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:
2026-06-03 11:17:41 +01:00
parent 169d7dc6e3
commit bffb984ebb
30 changed files with 1016 additions and 62 deletions
+43 -7
View File
@@ -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 {