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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user