trusted proxy, TOTP replay protection, new tests
- Trusted proxy config option for proxy-aware IP extraction used by rate limiting and audit logs; validates proxy IP before trusting X-Forwarded-For / X-Real-IP headers - TOTP replay protection via counter-based validation to reject reused codes within the same time step (±30s) - RateLimit middleware updated to extract client IP from proxy headers without IP spoofing risk - New tests for ClientIP proxy logic (spoofed headers, fallback) and extended rate-limit proxy coverage - HTMX error banner script integrated into web UI base - .gitignore updated for mciasdb build artifact Security: resolves CRIT-01 (TOTP replay attack) and DEF-03 (proxy-unaware rate limiting); gRPC TOTP enrollment aligned with REST via StorePendingTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,8 +72,14 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
||||
|
||||
if acct.TOTPRequired {
|
||||
if req.TotpCode == "" {
|
||||
// Security (DEF-08): password was already verified, so a missing
|
||||
// TOTP code means the gRPC client needs to re-prompt the user —
|
||||
// it is not a credential failure. Do NOT increment the lockout
|
||||
// counter here; doing so would lock out well-behaved clients that
|
||||
// call Login in two steps (password first, TOTP second) and would
|
||||
// also let an attacker trigger account lockout by omitting the
|
||||
// code after a successful password guess.
|
||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
|
||||
}
|
||||
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
@@ -81,12 +87,19 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
||||
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
|
||||
valid, counter, err := auth.ValidateTOTP(secret, req.TotpCode)
|
||||
if err != nil || !valid {
|
||||
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
// Security (CRIT-01): reject replay of a code already used within
|
||||
// its ±30-second validity window.
|
||||
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
|
||||
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"totp_replay"}`) //nolint:errcheck
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
// Login succeeded: clear any outstanding failure counter.
|
||||
@@ -199,7 +212,12 @@ func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOT
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
if err := a.s.db.SetTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
||||
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required is
|
||||
// not set to 1 until the user confirms the code via ConfirmTOTP. Calling
|
||||
// SetTOTP here would immediately lock the account behind TOTP before the
|
||||
// user has had a chance to configure their authenticator app — matching the
|
||||
// behaviour of the REST EnrollTOTP handler at internal/server/server.go.
|
||||
if err := a.s.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
@@ -232,10 +250,15 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
valid, err := auth.ValidateTOTP(secret, req.Code)
|
||||
valid, counter, err := auth.ValidateTOTP(secret, req.Code)
|
||||
if err != nil || !valid {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
|
||||
}
|
||||
// Security (CRIT-01): record the counter even during enrollment confirmation
|
||||
// so the same code cannot be replayed immediately after confirming.
|
||||
if err := a.s.db.CheckAndUpdateTOTPCounter(acct.ID, counter); err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
|
||||
}
|
||||
|
||||
// SetTOTP with existing enc/nonce sets totp_required=1, confirming enrollment.
|
||||
if err := a.s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user