fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,132 +2,306 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
googleTokenInfoURL = "https://oauth2.googleapis.com/tokeninfo"
|
||||
// googleKeysURL is Google's JWKS endpoint for ID-token signature verification.
|
||||
googleKeysURL = "https://www.googleapis.com/oauth2/v3/certs"
|
||||
googleKeysCacheTTL = 24 * time.Hour
|
||||
googleKeysCacheKey = "google:public_keys"
|
||||
)
|
||||
|
||||
// googleIssuers is the set of valid `iss` claim values for a Google ID token.
|
||||
var googleIssuers = map[string]bool{
|
||||
"accounts.google.com": true,
|
||||
"https://accounts.google.com": true,
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidGoogleToken = errors.New("invalid Google ID token")
|
||||
ErrGoogleTokenExpired = errors.New("Google ID token has expired")
|
||||
ErrInvalidGoogleAudience = errors.New("invalid Google token audience")
|
||||
ErrInvalidGoogleIssuer = errors.New("invalid Google token issuer")
|
||||
ErrGoogleKeyNotFound = errors.New("Google public key not found")
|
||||
)
|
||||
|
||||
// GoogleTokenInfo represents the response from Google's token info endpoint
|
||||
type GoogleTokenInfo struct {
|
||||
Sub string `json:"sub"` // Unique Google user ID
|
||||
Email string `json:"email"` // User's email
|
||||
EmailVerified string `json:"email_verified"` // "true" or "false"
|
||||
Name string `json:"name"` // Full name
|
||||
GivenName string `json:"given_name"` // First name
|
||||
FamilyName string `json:"family_name"` // Last name
|
||||
Picture string `json:"picture"` // Profile picture URL
|
||||
Aud string `json:"aud"` // Audience (client ID)
|
||||
Azp string `json:"azp"` // Authorized party
|
||||
Exp string `json:"exp"` // Expiration time
|
||||
Iss string `json:"iss"` // Issuer
|
||||
// GoogleJWKS represents Google's JSON Web Key Set.
|
||||
type GoogleJWKS struct {
|
||||
Keys []GoogleJWK `json:"keys"`
|
||||
}
|
||||
|
||||
// IsEmailVerified returns whether the email is verified
|
||||
// GoogleJWK represents a single JSON Web Key from Google.
|
||||
type GoogleJWK struct {
|
||||
Kty string `json:"kty"` // Key type (RSA)
|
||||
Kid string `json:"kid"` // Key ID
|
||||
Use string `json:"use"` // Key use (sig)
|
||||
Alg string `json:"alg"` // Algorithm (RS256)
|
||||
N string `json:"n"` // RSA modulus
|
||||
E string `json:"e"` // RSA exponent
|
||||
}
|
||||
|
||||
// GoogleTokenClaims represents the claims in a Google ID token JWT.
|
||||
type GoogleTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Azp string `json:"azp,omitempty"` // Authorized party
|
||||
}
|
||||
|
||||
// GoogleTokenInfo is the verified, caller-facing view of a Google ID token.
|
||||
type GoogleTokenInfo struct {
|
||||
Sub string // Unique Google user ID
|
||||
Email string
|
||||
EmailVerified string // "true" or "false" — string for caller compatibility
|
||||
Name string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
Picture string
|
||||
Aud string
|
||||
Azp string
|
||||
Iss string
|
||||
}
|
||||
|
||||
// IsEmailVerified returns whether the email is verified.
|
||||
func (t *GoogleTokenInfo) IsEmailVerified() bool {
|
||||
return t.EmailVerified == "true"
|
||||
}
|
||||
|
||||
// GoogleAuthService handles Google Sign In token verification
|
||||
// GoogleAuthService handles Google Sign In token verification.
|
||||
type GoogleAuthService struct {
|
||||
cache *CacheService
|
||||
config *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGoogleAuthService creates a new Google auth service
|
||||
// NewGoogleAuthService creates a new Google auth service.
|
||||
func NewGoogleAuthService(cache *CacheService, cfg *config.Config) *GoogleAuthService {
|
||||
return &GoogleAuthService{
|
||||
cache: cache,
|
||||
config: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyIDToken verifies a Google ID token and returns the token info
|
||||
// VerifyIDToken verifies a Google ID token locally (audit C2/C3): it checks
|
||||
// the RS256 signature against Google's published JWKS and the iss, aud, and
|
||||
// exp claims. It never sends the token to a third-party endpoint, so it no
|
||||
// longer depends on the deprecated tokeninfo service and never leaks the
|
||||
// token in a request URL.
|
||||
func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) {
|
||||
// Call Google's tokeninfo endpoint to verify the token
|
||||
url := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, idToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Parse the token header to get the key ID.
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidGoogleToken
|
||||
}
|
||||
|
||||
var tokenInfo GoogleTokenInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token info: %w", err)
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token header: %w", err)
|
||||
}
|
||||
var header struct {
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
}
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token header: %w", err)
|
||||
}
|
||||
|
||||
// Verify the audience matches our client ID(s)
|
||||
if !s.verifyAudience(tokenInfo.Aud, tokenInfo.Azp) {
|
||||
publicKey, err := s.getPublicKey(ctx, header.Kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse and verify the signature. jwt v5 validates exp/iat/nbf automatically.
|
||||
token, err := jwt.ParseWithClaims(idToken, &GoogleTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrGoogleTokenExpired
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*GoogleTokenClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidGoogleToken
|
||||
}
|
||||
|
||||
// Verify the issuer (audit C3).
|
||||
if !googleIssuers[claims.Issuer] {
|
||||
return nil, ErrInvalidGoogleIssuer
|
||||
}
|
||||
|
||||
// Verify the audience matches one of our configured client IDs.
|
||||
if !s.verifyAudience(claims.Audience, claims.Azp) {
|
||||
return nil, ErrInvalidGoogleAudience
|
||||
}
|
||||
|
||||
// Verify the token is not expired (tokeninfo endpoint already checks this,
|
||||
// but we double-check for security)
|
||||
if tokenInfo.Sub == "" {
|
||||
if claims.Subject == "" {
|
||||
return nil, ErrInvalidGoogleToken
|
||||
}
|
||||
|
||||
return &tokenInfo, nil
|
||||
emailVerified := "false"
|
||||
if claims.EmailVerified {
|
||||
emailVerified = "true"
|
||||
}
|
||||
aud := ""
|
||||
if len(claims.Audience) > 0 {
|
||||
aud = claims.Audience[0]
|
||||
}
|
||||
return &GoogleTokenInfo{
|
||||
Sub: claims.Subject,
|
||||
Email: claims.Email,
|
||||
EmailVerified: emailVerified,
|
||||
Name: claims.Name,
|
||||
GivenName: claims.GivenName,
|
||||
FamilyName: claims.FamilyName,
|
||||
Picture: claims.Picture,
|
||||
Aud: aud,
|
||||
Azp: claims.Azp,
|
||||
Iss: claims.Issuer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyAudience checks if the token audience matches our client ID(s).
|
||||
// In production (non-debug), an empty clientID causes verification to fail
|
||||
// rather than silently bypassing the check.
|
||||
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool {
|
||||
// verifyAudience checks the token audience against our configured client IDs.
|
||||
// In production (non-debug) an empty client ID fails verification rather than
|
||||
// silently bypassing the check.
|
||||
func (s *GoogleAuthService) verifyAudience(audience jwt.ClaimStrings, azp string) bool {
|
||||
clientID := s.config.GoogleAuth.ClientID
|
||||
if clientID == "" {
|
||||
if s.config.Server.Debug {
|
||||
// In debug mode only, skip audience verification for local development
|
||||
// In debug mode only, skip audience verification for local development.
|
||||
return s.config.Server.Debug
|
||||
}
|
||||
|
||||
candidates := []string{clientID}
|
||||
if id := s.config.GoogleAuth.AndroidClientID; id != "" {
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
if id := s.config.GoogleAuth.IOSClientID; id != "" {
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
|
||||
for _, want := range candidates {
|
||||
if azp == want {
|
||||
return true
|
||||
}
|
||||
// In production, missing client ID means we cannot verify the audience
|
||||
return false
|
||||
for _, aud := range audience {
|
||||
if aud == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check both aud and azp (Android vs iOS may use different values)
|
||||
if aud == clientID || azp == clientID {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check Android client ID if configured
|
||||
androidClientID := s.config.GoogleAuth.AndroidClientID
|
||||
if androidClientID != "" && (aud == androidClientID || azp == androidClientID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check iOS client ID if configured
|
||||
iosClientID := s.config.GoogleAuth.IOSClientID
|
||||
if iosClientID != "" && (aud == iosClientID || azp == iosClientID) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getPublicKey returns the RSA public key for the given key ID, using a
|
||||
// Redis-cached copy of Google's JWKS and re-fetching once on a cache miss
|
||||
// (Google rotates signing keys roughly daily).
|
||||
func (s *GoogleAuthService) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
||||
keys, err := s.getCachedKeys(ctx)
|
||||
if err != nil || keys == nil {
|
||||
keys, err = s.fetchGooglePublicKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if pubKey, ok := keys[kid]; ok {
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
// Cache miss for this kid — keys may have rotated; fetch fresh.
|
||||
keys, err = s.fetchGooglePublicKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pubKey, ok := keys[kid]; ok {
|
||||
return pubKey, nil
|
||||
}
|
||||
return nil, ErrGoogleKeyNotFound
|
||||
}
|
||||
|
||||
// getCachedKeys retrieves cached Google public keys from Redis.
|
||||
func (s *GoogleAuthService) getCachedKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
|
||||
if s.cache == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := s.cache.GetString(ctx, googleKeysCacheKey)
|
||||
if err != nil || data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var jwks GoogleJWKS
|
||||
if err := json.Unmarshal([]byte(data), &jwks); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.parseJWKS(&jwks), nil
|
||||
}
|
||||
|
||||
// fetchGooglePublicKeys fetches Google's JWKS and caches it.
|
||||
func (s *GoogleAuthService) fetchGooglePublicKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleKeysURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Google keys: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Google keys endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
var jwks GoogleJWKS
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode Google keys: %w", err)
|
||||
}
|
||||
if s.cache != nil {
|
||||
keysJSON, _ := json.Marshal(jwks)
|
||||
_ = s.cache.SetString(ctx, googleKeysCacheKey, string(keysJSON), googleKeysCacheTTL)
|
||||
}
|
||||
return s.parseJWKS(&jwks), nil
|
||||
}
|
||||
|
||||
// parseJWKS converts Google's JWKS into a map of RSA public keys by key ID.
|
||||
func (s *GoogleAuthService) parseJWKS(jwks *GoogleJWKS) map[string]*rsa.PublicKey {
|
||||
keys := make(map[string]*rsa.PublicKey)
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kty != "RSA" {
|
||||
continue
|
||||
}
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
e := 0
|
||||
for _, b := range eBytes {
|
||||
e = e<<8 + int(b)
|
||||
}
|
||||
keys[key.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user