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
+109 -18
View File
@@ -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
}