feat: auth system hardening with token revocation, password management, and error sanitization

- Add token revocation with in-memory blacklist and periodic cleanup (SEC-M1)
- Add POST /api/auth/logout endpoint
- Fix OIDC auth_token cookie to HttpOnly with exchange endpoint (SEC-H3)
- Add password complexity validation (min 8 chars) (SEC-M2)
- Prevent admin self-deletion (SEC-M3)
- Add PUT /api/auth/users/{uid} for role/email updates (FUNC-M1)
- Add PUT /api/auth/users/{uid}/password for password changes (FUNC-H1)
- Sanitize error messages in auth handlers (SEC-M4)
This commit is contained in:
2026-04-04 12:43:45 +03:00
parent f71c314262
commit 98ee2bcd9a
4 changed files with 236 additions and 20 deletions
+43 -1
View File
@@ -3,8 +3,10 @@ package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -31,6 +33,8 @@ type jwtClaims struct {
// LocalAuth handles password hashing and JWT token management for local auth mode.
type LocalAuth struct {
jwtSecret []byte
mu sync.RWMutex
blacklist map[string]time.Time // token hash -> expiry time
}
// NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key
@@ -38,8 +42,46 @@ type LocalAuth struct {
func NewLocalAuth(encKey [32]byte) *LocalAuth {
mac := hmac.New(sha256.New, encKey[:])
mac.Write([]byte("docker-watcher-jwt-secret"))
return &LocalAuth{
la := &LocalAuth{
jwtSecret: mac.Sum(nil),
blacklist: make(map[string]time.Time),
}
// Periodically clean expired blacklist entries.
go la.cleanBlacklist()
return la
}
// RevokeToken adds a token to the blacklist.
func (la *LocalAuth) RevokeToken(tokenString string) {
hash := sha256.Sum256([]byte(tokenString))
key := hex.EncodeToString(hash[:])
la.mu.Lock()
defer la.mu.Unlock()
la.blacklist[key] = time.Now().Add(TokenExpiry)
}
// IsRevoked checks if a token has been revoked.
func (la *LocalAuth) IsRevoked(tokenString string) bool {
hash := sha256.Sum256([]byte(tokenString))
key := hex.EncodeToString(hash[:])
la.mu.RLock()
defer la.mu.RUnlock()
_, exists := la.blacklist[key]
return exists
}
// cleanBlacklist removes expired entries from the blacklist every hour.
func (la *LocalAuth) cleanBlacklist() {
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
la.mu.Lock()
now := time.Now()
for k, expiry := range la.blacklist {
if now.After(expiry) {
delete(la.blacklist, k)
}
}
la.mu.Unlock()
}
}
+8 -3
View File
@@ -18,7 +18,7 @@ const claimsKey contextKey = "auth_claims"
func Middleware(la *LocalAuth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := extractToken(r)
tokenStr := ExtractToken(r)
if tokenStr == "" {
http.Error(w, `{"success":false,"error":"authentication required"}`, http.StatusUnauthorized)
return
@@ -30,6 +30,11 @@ func Middleware(la *LocalAuth) func(http.Handler) http.Handler {
return
}
if la.IsRevoked(tokenStr) {
http.Error(w, `{"success":false,"error":"token has been revoked"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
@@ -55,8 +60,8 @@ func ClaimsFromContext(ctx context.Context) (Claims, bool) {
return claims, ok
}
// extractToken gets the JWT from the Authorization header or "token" query param.
func extractToken(r *http.Request) string {
// ExtractToken gets the JWT from the Authorization header or "token" query param.
func ExtractToken(r *http.Request) string {
// Try Authorization: Bearer <token>
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {