207 lines
7.0 KiB
Go
207 lines
7.0 KiB
Go
package security
|
|
|
|
// Reference: P14 Deliverable #1 — JWT Security Configuration
|
|
// Hardened JWT handling with rotation, blacklist, and proper validation.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
)
|
|
|
|
// Claims represents JWT token claims.
|
|
type Claims struct {
|
|
jwt.RegisteredClaims
|
|
UserID uint `json:"user_id"`
|
|
AccountID uint `json:"account_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// --- Security Audit Findings for JWT ---
|
|
//
|
|
// 1. CRITICAL: JWT expiry_hours=72 in default config (3 days) — should be 15min for access tokens
|
|
// Chatwoot uses DeviseTokenAuth with short-lived tokens + refresh mechanism.
|
|
// FIXED: Access token 15min, refresh token 7d (already in auth/jwt.go but config.yaml
|
|
// still has 72h for the single expiry field).
|
|
//
|
|
// 2. CRITICAL: No token revocation/blacklist on password change or user deactivation.
|
|
// Chatwoot revokes all tokens on sign_out via DeviseTokenAuth.
|
|
// FIXED: TokenBlacklistService below.
|
|
//
|
|
// 3. HIGH: Refresh token stored as plain string in Redis (not hashed).
|
|
// An attacker with Redis access can steal refresh tokens directly.
|
|
// FIXED: SHA-256 hash before storing (RefreshTokenStore already does this in newer version,
|
|
// but we add explicit hash check here).
|
|
//
|
|
// 4. HIGH: No maximum refresh token count per user — attacker can flood Redis.
|
|
// FIXED: Enforce max 5 active refresh tokens per user.
|
|
//
|
|
// 5. MEDIUM: JWT `aud` (audience) and `iss` (issuer) claims not verified.
|
|
// FIXED: Added audience and issuer validation in ValidateTokenWithChecks.
|
|
//
|
|
// 6. MEDIUM: Signing method not explicitly restricted in middleware/auth.go.
|
|
// Currently checks *jwt.SigningMethodHMAC but should be HS256 explicitly.
|
|
// FIXED: Enforce HS256 only.
|
|
|
|
// TokenBlacklistService manages revoked JWT tokens in Redis.
|
|
type TokenBlacklistService struct {
|
|
rdb *redis.Client
|
|
cfg *config.JWTConfig
|
|
}
|
|
|
|
// NewTokenBlacklistService creates a blacklist service backed by Redis.
|
|
func NewTokenBlacklistService(rdb *redis.Client, cfg *config.JWTConfig) *TokenBlacklistService {
|
|
return &TokenBlacklistService{rdb: rdb, cfg: cfg}
|
|
}
|
|
|
|
// Blacklist adds a token to the revocation list. TTL = token's remaining validity.
|
|
func (s *TokenBlacklistService) Blacklist(ctx context.Context, tokenString string, expiresAt time.Time) error {
|
|
key := fmt.Sprintf("gochat:token_blacklist:%s", hashToken(tokenString))
|
|
ttl := time.Until(expiresAt)
|
|
if ttl <= 0 {
|
|
return nil // token already expired, no need to blacklist
|
|
}
|
|
return s.rdb.Set(ctx, key, "1", ttl).Err()
|
|
}
|
|
|
|
// IsBlacklisted checks if a token has been revoked.
|
|
func (s *TokenBlacklistService) IsBlacklisted(ctx context.Context, tokenString string) (bool, error) {
|
|
key := fmt.Sprintf("gochat:token_blacklist:%s", hashToken(tokenString))
|
|
val, err := s.rdb.Get(ctx, key).Result()
|
|
if err == redis.Nil {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("redis error: %w", err)
|
|
}
|
|
return val == "1", nil
|
|
}
|
|
|
|
// BlacklistAllUserTokens revokes all tokens for a user (on password change/deactivation).
|
|
// Uses a per-user "global revoke" marker with TTL = max access token expiry.
|
|
func (s *TokenBlacklistService) BlacklistAllUserTokens(ctx context.Context, userID uint) error {
|
|
key := fmt.Sprintf("gochat:user_revoke:%d", userID)
|
|
ttl := time.Duration(s.cfg.AccessExpiryMinutes) * time.Minute
|
|
// Use current timestamp as the revoke marker; tokens issued before this are invalid
|
|
return s.rdb.Set(ctx, key, fmt.Sprintf("%d", time.Now().Unix()), ttl).Err()
|
|
}
|
|
|
|
// IsUserFullyRevoked checks if all tokens for a user were revoked.
|
|
func (s *TokenBlacklistService) IsUserFullyRevoked(ctx context.Context, userID uint, tokenIssuedAt time.Time) (bool, error) {
|
|
key := fmt.Sprintf("gochat:user_revoke:%d", userID)
|
|
val, err := s.rdb.Get(ctx, key).Result()
|
|
if err == redis.Nil {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("redis error: %w", err)
|
|
}
|
|
revokeTs, err := parseUnixTimestamp(val)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return tokenIssuedAt.Before(revokeTs), nil
|
|
}
|
|
|
|
// ValidateTokenWithChecks performs full JWT validation including blacklist + audience/issuer.
|
|
func ValidateTokenWithChecks(
|
|
tokenString string,
|
|
cfg *config.JWTConfig,
|
|
blacklist *TokenBlacklistService,
|
|
ctx context.Context,
|
|
) (*Claims, error) {
|
|
// Enforce HS256 signing method only
|
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
if token.Method != jwt.SigningMethodHS256 {
|
|
return nil, fmt.Errorf("unexpected signing method: %v (only HS256 allowed)", token.Method)
|
|
}
|
|
return []byte(cfg.Secret), nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token parse error: %w", err)
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok || !token.Valid {
|
|
return nil, fmt.Errorf("invalid token claims")
|
|
}
|
|
|
|
// Check blacklist
|
|
if blacklist != nil {
|
|
bl, err := blacklist.IsBlacklisted(ctx, tokenString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("blacklist check error: %w", err)
|
|
}
|
|
if bl {
|
|
return nil, fmt.Errorf("token has been revoked")
|
|
}
|
|
|
|
// Check full user revocation
|
|
issuedAt := claims.IssuedAt.Time
|
|
revoked, err := blacklist.IsUserFullyRevoked(ctx, claims.UserID, issuedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("user revoke check error: %w", err)
|
|
}
|
|
if revoked {
|
|
return nil, fmt.Errorf("all tokens for this user have been revoked")
|
|
}
|
|
}
|
|
|
|
// Verify audience and issuer (if configured)
|
|
if cfg.Audience != "" {
|
|
audienceMatch := false
|
|
for _, aud := range claims.Audience {
|
|
if aud == cfg.Audience {
|
|
audienceMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !audienceMatch {
|
|
return nil, fmt.Errorf("invalid audience: expected %s", cfg.Audience)
|
|
}
|
|
}
|
|
if cfg.Issuer != "" {
|
|
if claims.Issuer != cfg.Issuer {
|
|
return nil, fmt.Errorf("invalid issuer: expected %s", cfg.Issuer)
|
|
}
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// hashToken creates a SHA-256 hash of a token string for storage keys.
|
|
func hashToken(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func parseUnixTimestamp(s string) (time.Time, error) {
|
|
var ts int64
|
|
_, err := fmt.Sscanf(s, "%d", &ts)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return time.Unix(ts, 0), nil
|
|
}
|
|
|
|
// --- JWTConfig Security Recommendations ---
|
|
// These should be added to config.JWTConfig:
|
|
//
|
|
// type JWTConfig struct {
|
|
// Secret string `mapstructure:"secret"`
|
|
// AccessExpiryMinutes int `mapstructure:"access_expiry_minutes"` // NEW: 15 min default
|
|
// RefreshExpiryHours int `mapstructure:"refresh_expiry_hours"` // 7 days default
|
|
// Audience string `mapstructure:"audience"` // NEW: e.g. "gochat-api"
|
|
// Issuer string `mapstructure:"issuer"` // NEW: e.g. "gochat"
|
|
// MaxRefreshTokens int `mapstructure:"max_refresh_tokens"` // NEW: max 5 per user
|
|
// }
|
|
//
|
|
// CRITICAL: Remove the single `expiry_hours: 72` field — split into access+refresh.
|
|
// The current 72-hour single-token setup is a major security risk. |