fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

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:
Trey t
2026-05-16 22:28:33 -05:00
parent 2004f9c5b2
commit c77ff07ce9
59 changed files with 2819 additions and 1245 deletions
+132 -52
View File
@@ -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 000000999999 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 {