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:
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -36,13 +37,32 @@ var (
|
||||
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
|
||||
)
|
||||
|
||||
// Per-account login lockout (audit M5, hardened per MEDIUM-3).
|
||||
const (
|
||||
// maxLoginFailureIPs is how many DISTINCT source IPs may fail to log in to
|
||||
// one account within the window before that account is locked. Counting
|
||||
// distinct IPs (not raw attempts) means a single attacker who knows a
|
||||
// victim's email cannot lock the victim out by spamming failures — only a
|
||||
// genuinely distributed credential-stuffing attack reaches this threshold.
|
||||
maxLoginFailureIPs = 5
|
||||
// loginLockWindow is how long the failed-IP set persists; it is refreshed
|
||||
// on each failure so an active attack keeps the window open.
|
||||
loginLockWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
// AuthService handles authentication business logic
|
||||
type AuthService struct {
|
||||
userRepo *repositories.UserRepository
|
||||
notificationRepo *repositories.NotificationRepository
|
||||
cache *CacheService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// SetCacheService wires Redis for per-account login-failure tracking (M5).
|
||||
func (s *AuthService) SetCacheService(cache *CacheService) {
|
||||
s.cache = cache
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
@@ -56,34 +76,89 @@ func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.N
|
||||
s.notificationRepo = notificationRepo
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a token
|
||||
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
||||
// dummyPasswordHash is a valid bcrypt hash used to keep login response time
|
||||
// constant when the account does not exist (audit LIVE-L11). It is computed
|
||||
// once at startup; the plaintext it hashes is irrelevant and never used.
|
||||
var dummyPasswordHash = func() string {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte("honeydue-login-timing-equalizer"), models.BcryptCost)
|
||||
if err != nil {
|
||||
return "" // CompareHashAndPassword against "" always fails — safe
|
||||
}
|
||||
return string(h)
|
||||
}()
|
||||
|
||||
// freshToken mints a new auth token for the user and evicts any prior token's
|
||||
// Redis cache entry (audit MEDIUM-1). Without the eviction a re-login would
|
||||
// not actually kill a previously-issued token until the cache TTL lapsed — a
|
||||
// stolen token would keep working for up to 5 minutes after the victim
|
||||
// re-authenticates. A cache-eviction failure is logged, not fatal: the token
|
||||
// row is already gone, so the stale entry simply ages out on its own.
|
||||
func (s *AuthService) freshToken(ctx context.Context, userID uint) (*models.AuthToken, error) {
|
||||
token, oldHashes, err := s.userRepo.WithContext(ctx).CreateFreshToken(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.cache != nil && len(oldHashes) > 0 {
|
||||
if cErr := s.cache.InvalidateAuthTokenHashes(ctx, oldHashes...); cErr != nil {
|
||||
log.Warn().Err(cErr).Uint("user_id", userID).
|
||||
Msg("failed to evict prior auth-token cache entries on re-login")
|
||||
}
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a token. clientIP is the request's
|
||||
// source IP (echo c.RealIP()), used for the distributed-attack lockout.
|
||||
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest, clientIP string) (*responses.LoginResponse, error) {
|
||||
// Find user by username or email
|
||||
identifier := req.Username
|
||||
if identifier == "" {
|
||||
identifier = req.Email
|
||||
}
|
||||
lockKey := strings.ToLower(strings.TrimSpace(identifier))
|
||||
|
||||
// Audit M5 (hardened per MEDIUM-3): per-account lockout keyed on the set
|
||||
// of distinct source IPs that have failed. Once enough distinct IPs have
|
||||
// failed for one account within the window, reject — this still catches
|
||||
// distributed credential stuffing, without letting a single attacker lock
|
||||
// a victim out by spamming failed logins from one IP.
|
||||
if s.cache != nil && lockKey != "" {
|
||||
if n, cErr := s.cache.LoginFailureIPCount(ctx, lockKey); cErr == nil && n >= maxLoginFailureIPs {
|
||||
return nil, apperrors.TooManyRequests("error.too_many_login_attempts")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.userRepo.WithContext(ctx).FindByUsernameOrEmail(identifier)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||||
}
|
||||
if err != nil && !errors.Is(err, repositories.ErrUserNotFound) {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, apperrors.Unauthorized("error.account_inactive")
|
||||
// Constant-time login (audit LIVE-L11): always run a bcrypt comparison,
|
||||
// even when the account does not exist or is inactive, so response
|
||||
// timing never reveals which emails are real accounts. Compare against
|
||||
// the user's hash when available, otherwise a fixed dummy hash.
|
||||
passwordHash := dummyPasswordHash
|
||||
if user != nil {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
|
||||
|
||||
// Verify password
|
||||
if !user.CheckPassword(req.Password) {
|
||||
// One generic error for not-found, inactive, and wrong-password
|
||||
// (audit L1) — none of them disclose which condition failed.
|
||||
if user == nil || !user.IsActive || !passwordOK {
|
||||
if s.cache != nil && lockKey != "" {
|
||||
_, _ = s.cache.RegisterLoginFailure(ctx, lockKey, clientIP, loginLockWindow)
|
||||
}
|
||||
return nil, apperrors.Unauthorized("error.invalid_credentials")
|
||||
}
|
||||
|
||||
// Successful authentication — clear the failure counter (audit M5).
|
||||
if s.cache != nil && lockKey != "" {
|
||||
_ = s.cache.ClearLoginFailures(ctx, lockKey)
|
||||
}
|
||||
|
||||
// Get or create auth token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -95,7 +170,7 @@ func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*r
|
||||
}
|
||||
|
||||
return &responses.LoginResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
}, nil
|
||||
}
|
||||
@@ -176,13 +251,13 @@ func (s *AuthService) Register(ctx context.Context, req *requests.RegisterReques
|
||||
}
|
||||
|
||||
// Create auth token (outside transaction since token generation is idempotent)
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, "", apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return &responses.RegisterResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
Message: "Registration successful. Please check your email to verify your account.",
|
||||
}, code, nil
|
||||
@@ -243,7 +318,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, tokenKey string, userID
|
||||
}
|
||||
|
||||
return &responses.RefreshTokenResponse{
|
||||
Token: newToken.Key,
|
||||
Token: newToken.Plaintext,
|
||||
Message: "Token refreshed successfully.",
|
||||
}, nil
|
||||
}
|
||||
@@ -390,26 +465,26 @@ func (s *AuthService) VerifyEmail(ctx context.Context, userID uint, code string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find and validate confirmation code
|
||||
confirmCode, err := s.userRepo.WithContext(ctx).FindConfirmationCode(userID, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrCodeNotFound) {
|
||||
// Audit M4: validate the code, consume it, and flip the verified flag in
|
||||
// one transaction so the three writes commit or roll back together.
|
||||
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
|
||||
confirmCode, err := txRepo.FindConfirmationCode(userID, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return txRepo.SetProfileVerified(userID, true)
|
||||
})
|
||||
if txErr != nil {
|
||||
if errors.Is(txErr, repositories.ErrCodeNotFound) {
|
||||
return apperrors.BadRequest("error.invalid_verification_code")
|
||||
}
|
||||
if errors.Is(err, repositories.ErrCodeExpired) {
|
||||
if errors.Is(txErr, repositories.ErrCodeExpired) {
|
||||
return apperrors.BadRequest("error.verification_code_expired")
|
||||
}
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
if err := s.userRepo.WithContext(ctx).MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Set profile as verified
|
||||
if err := s.userRepo.WithContext(ctx).SetProfileVerified(userID, true); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
return apperrors.Internal(txErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -476,7 +551,7 @@ func (s *AuthService) ForgotPassword(ctx context.Context, email string) (string,
|
||||
expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry)
|
||||
|
||||
// Hash the code before storing
|
||||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), models.BcryptCost)
|
||||
if err != nil {
|
||||
return "", nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -596,7 +671,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -605,7 +680,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
|
||||
|
||||
return &responses.AppleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -638,7 +713,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID)
|
||||
token, err := s.freshToken(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -653,7 +728,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
return &responses.AppleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(existingUser),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -704,7 +779,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
// Create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -716,7 +791,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
||||
}
|
||||
|
||||
return &responses.AppleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: true,
|
||||
}, nil
|
||||
@@ -749,7 +824,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -758,7 +833,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
|
||||
|
||||
return &responses.GoogleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -794,7 +869,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
// Get or create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID)
|
||||
token, err := s.freshToken(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -809,7 +884,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
return &responses.GoogleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(existingUser),
|
||||
IsNewUser: false,
|
||||
}, nil
|
||||
@@ -861,7 +936,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
// Create token
|
||||
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
|
||||
token, err := s.freshToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
@@ -873,7 +948,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
}
|
||||
|
||||
return &responses.GoogleSignInResponse{
|
||||
Token: token.Key,
|
||||
Token: token.Plaintext,
|
||||
User: responses.NewUserResponse(user),
|
||||
IsNewUser: true,
|
||||
}, nil
|
||||
@@ -882,14 +957,19 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
|
||||
// Helper functions
|
||||
|
||||
func generateSixDigitCode() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
if num < 0 {
|
||||
num = -num
|
||||
// Uniform 000000–999999 via rejection sampling on crypto/rand,
|
||||
// removing the modulo bias of `n % 1000000` (audit H4).
|
||||
for {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
continue
|
||||
}
|
||||
// 4294000000 is the largest multiple of 1e6 <= MaxUint32.
|
||||
n := binary.BigEndian.Uint32(b[:])
|
||||
if n < 4294000000 {
|
||||
return fmt.Sprintf("%06d", n%1000000)
|
||||
}
|
||||
}
|
||||
code := num % 1000000
|
||||
return fmt.Sprintf("%06d", code)
|
||||
}
|
||||
|
||||
func generateResetToken() string {
|
||||
|
||||
Reference in New Issue
Block a user