Skip to content
44 changes: 20 additions & 24 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,10 +677,15 @@ func main() {
// OR26-03 — the tenant's grace window. Deployment decides WHO is privileged;
// each tenant decides HOW LONG they may defer (default 7 days).
mfaPolicyRepo := repository.NewGormMFAPolicyRepository(database.DB)
// #688 — ten failed sign-ins on one address, from any source, pause it for
// fifteen minutes; a password reset ends the pause. Counted in Redis (the
// store #689 built) so the budget holds across instances.
loginAttemptStore := authmfa.NewAttemptStore(redisClientInstance)
loginUseCase := auth.NewLoginUseCase(userRepo, tokenManager, passwordHasher).
WithMFA(mfaRepo).
RequireMFAForRoles(mfaRequiredRoles, mfaRequiredBusinessRoles).
WithMFAPolicies(mfaPolicyRepo)
WithMFAPolicies(mfaPolicyRepo).
WithAttemptLimits(loginAttemptStore)
registerUseCase := auth.NewRegisterUseCase(userRepo, orgRepo, notificationService, passwordHasher).
// Anchors t0 for the time-to-Aha histogram.
WithActivation(activationRecorder).
Expand Down Expand Up @@ -728,7 +733,7 @@ func main() {
requestResetUseCase := auth.NewRequestPasswordResetUseCase(userRepo, passwordResetRepo, securityMailer)
confirmResetUseCase := auth.NewConfirmPasswordResetUseCase(
userRepo, passwordResetRepo, passwordHasher, passwordPolicy, tokenManager, securityMailer,
)
).WithLoginLockClearer(loginAttemptStore)
passwordHandler := authhandler.NewPasswordHandler(
requestResetUseCase, confirmResetUseCase, passwordPolicy, appBaseURL, authAudit,
).WithChangePassword(auth.NewChangePasswordUseCase(
Expand Down Expand Up @@ -850,25 +855,16 @@ func main() {
})
})

// Brute-force protection on credential endpoints (5 attempts / 15 min per IP).
// Backed by Redis so the counter is shared across every instance of a
// horizontally-scaled deployment; degrades gracefully to a per-instance
// in-memory limiter if Redis is unreachable.
// Per-IP throttles on the auth routes, one bucket per purpose (#688). Backed
// by Redis so the counters are shared across every instance; degrades to a
// per-instance in-memory limiter if Redis is unreachable. Guessing against
// one account is bounded per address in the login use case, not here.
authLimiterStore := middleware.NewRedisRateLimitStore(redisClientInstance)
// Per-IP throttle on auth endpoints. 5/15min locked out legitimate users (a
// couple of mistyped passwords, MFA re-auth, or shared-NAT colleagues) for a
// quarter hour (OR-BUG-008). 15/5min still blocks rapid brute force (~3/min
// sustained) without punishing normal use. Per-account lockout + captcha are
// the stronger long-term controls (tracked separately).
authRateLimit := middleware.RateLimit(middleware.RateLimitConfig{
MaxRequests: 15,
WindowSize: 5 * time.Minute,
Store: authLimiterStore,
})
authLimits := middleware.NewAuthLimiters(authLimiterStore)

// Clean Architecture Auth Routes
api.Post("/auth/login", authRateLimit, cleanAuthHandler.Login)
api.Post("/auth/register", authRateLimit, cleanAuthHandler.Register)
api.Post("/auth/login", authLimits.Login, cleanAuthHandler.Login)
api.Post("/auth/register", authLimits.Register, cleanAuthHandler.Register)
api.Post("/auth/refresh", cleanAuthHandler.RefreshToken)
api.Post("/auth/logout", cleanAuthHandler.Logout)

Expand All @@ -878,16 +874,16 @@ func main() {
// path. /users/me is served by the profile handler (#719).

// --- Password reset (public) ---
// Both legs sit behind the per-IP auth limiter on top of the per-address cap
// Both legs share the per-IP reset bucket, on top of the per-address cap
// the use case enforces: the address cap stops targeting one account, the IP
// limiter stops sweeping many.
//
// /password/check is unauthenticated by necessity — it serves the strength
// meter on the registration and reset screens, where there is no session. It
// discloses nothing: the caller already knows the password they typed.
api.Post("/auth/password/forgot", authRateLimit, passwordHandler.ForgotPassword)
api.Post("/auth/password/reset", authRateLimit, passwordHandler.ResetPassword)
api.Post("/auth/password/check", authRateLimit, passwordHandler.CheckPassword)
api.Post("/auth/password/forgot", authLimits.Reset, passwordHandler.ForgotPassword)
api.Post("/auth/password/reset", authLimits.Reset, passwordHandler.ResetPassword)
api.Post("/auth/password/check", authLimits.Check, passwordHandler.CheckPassword)

// --- OAuth2 Routes ---
api.Get("/auth/oauth2/login/:provider", handlers.OAuth2Login)
Expand Down Expand Up @@ -1142,7 +1138,7 @@ func main() {
api.Post("/auth/mfa/setup", mfaEnrollmentGuard, mfaHandler.Setup)
api.Post("/auth/mfa/verify", mfaEnrollmentGuard, mfaHandler.Verify)
// Throttled like /auth/password/change: the body carries a password guess.
protected.Post("/auth/mfa/disable", authRateLimit, mfaHandler.Disable)
protected.Post("/auth/mfa/disable", authLimits.Reauth, mfaHandler.Disable)

// --- MFA policy (OR26-03) — "force MFA after N days" -----------------------
// Reading is open to any authenticated member: everyone subject to a deadline
Expand All @@ -1160,7 +1156,7 @@ func main() {
// In-session password change (#720, D-047). Acts on the session's own user;
// behind the auth rate limiter so a wrong current password spends the same
// budget a failed sign-in does.
protected.Post("/auth/password/change", authRateLimit, passwordHandler.ChangePassword)
protected.Post("/auth/password/change", authLimits.Reauth, passwordHandler.ChangePassword)
protected.Delete("/auth/sessions/:id", sessionHandler.RevokeSession)

// Organization switching — list the orgs the user may enter, and switch into
Expand Down
49 changes: 41 additions & 8 deletions backend/internal/application/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ type LoginUseCase struct {
mfaPolicies MFAPolicyReader
// now is injectable so the grace arithmetic is testable without sleeping.
now func() time.Time
// dummyHash is compared against when there is no account to compare with,
// so an unknown address costs what a real one does (#688).
dummyHash string
// attempts bounds failed sign-ins per address (#688). Optional.
attempts LoginAttemptStore
}

// PasswordUpgrader is the half of the hasher that knows whether a stored hash
Expand Down Expand Up @@ -109,13 +114,26 @@ type MFAGraceAnchorWriter interface {

// NewLoginUseCase creates a new login use case
func NewLoginUseCase(userRepo UserRepository, tokenManager *auth.TokenManager, passwordHasher auth.PasswordHasher) *LoginUseCase {
return &LoginUseCase{
uc := &LoginUseCase{
userRepo: userRepo,
tokenManager: tokenManager,
passwordHasher: passwordHasher,
}
// Hashed once, with the live hasher, so it carries today's cost parameters.
// If hashing fails the comparison runs against an empty hash, which the
// Argon2id hasher refuses at full cost anyway (spendArgon2idCost).
if dummy, err := passwordHasher.Hash(loginTimingDummyPassword); err == nil {
uc.dummyHash = dummy
} else {
log.Printf("login: timing-equaliser hash unavailable: %v", err)
}
return uc
}

// loginTimingDummyPassword is hashed at start-up and never matches anything a
// person can sign in with: it is compared only when there is no account.
const loginTimingDummyPassword = "openrisk-login-timing-equaliser"

// RequireMFAForRoles makes MFA mandatory for the named org roles and business
// roles.
//
Expand Down Expand Up @@ -169,24 +187,39 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input LoginInput) (*LoginOu
return nil, domain.NewValidationError("password is required")
}

// Find user by email
user, err := uc.userRepo.GetByEmail(ctx, input.Email)
// One spelling of an address, as sign-up and reset use (#688).
email := domain.NormaliseEmail(input.Email)

// A locked address is refused before the database or the hasher is asked
// anything, so the refusal costs the same whether the account exists.
if left := uc.lockedFor(ctx, email); left > 0 {
return nil, &LoginLockedError{RetryAfter: left}
}

user, err := uc.userRepo.GetByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("authentication failed")
}

// Every refusal for bad credentials costs one password comparison (#688).
// Answering an unknown address or a disabled account before hashing made
// those replies several times faster than a wrong password, and the clock
// told an attacker which addresses held an account.
if user == nil {
uc.passwordHasher.Verify(uc.dummyHash, input.Password)
uc.recordFailure(ctx, email)
return nil, domain.NewValidationError("invalid credentials")
}

// Check if user is active
passwordOK := uc.passwordHasher.Verify(user.Password, input.Password)
if !user.IsActive {
uc.recordFailure(ctx, email)
return nil, domain.NewValidationError("account is disabled")
}

// Verify password using Argon2id (OWASP recommended)
if !uc.passwordHasher.Verify(user.Password, input.Password) {
if !passwordOK {
uc.recordFailure(ctx, email)
return nil, domain.NewValidationError("invalid credentials")
}
uc.clearFailures(ctx, email)

// The password is correct and the plaintext is in hand: if the stored hash
// was written with a lower Argon2id cost than today's, rewrite it now.
Expand Down
126 changes: 126 additions & 0 deletions backend/internal/application/auth/login_limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package auth

import (
"context"
"log"
"time"

"github.com/opendefender/openrisk/internal/domain"
)

// Per-address limits on password sign-in (#688). The per-IP limiter on the
// route does nothing against guessing one account from many addresses; this
// does, because the key is the address being signed into. Same shape and
// numbers as the MFA challenge lock (#689).
const (
// LoginMaxFailuresPerAddress is how many bad-credential refusals one
// address takes, from any source, before it is locked.
LoginMaxFailuresPerAddress = 10
// LoginFailureWindow is counted from the first failure.
LoginFailureWindow = 15 * time.Minute
// LoginLockDuration is fixed: attempts during the lock neither count nor
// extend it, so a lock never outlasts this. A password reset ends it early.
LoginLockDuration = 15 * time.Minute
)

// LoginAttemptStore holds the per-address counters and locks. It is the part of
// MFAAttemptStore login needs, so authmfa.AttemptStore serves both; it must be
// shared across instances, or each replica multiplies the budget.
type LoginAttemptStore interface {
Increment(ctx context.Context, key string, ttl time.Duration) (int64, error)
Lock(ctx context.Context, key string, ttl time.Duration) (bool, error)
LockedFor(ctx context.Context, key string) (time.Duration, error)
Delete(ctx context.Context, key string) error
}

// LoginLockedError means the address refuses sign-in for a while. It is
// returned without the password being looked at.
type LoginLockedError struct {
RetryAfter time.Duration
}

func (e *LoginLockedError) Error() string {
return "sign-in is paused for this address after repeated failures"
}

// The keys carry the address hashed, never in clear. They are computed for
// every address, known or not, so a lock says nothing about whether an account
// exists.
func loginFailKey(email string) string { return "login:fail:" + domain.HashEmailForReset(email) }
func loginLockKey(email string) string { return "login:lock:" + domain.HashEmailForReset(email) }

// WithAttemptLimits turns the per-address limits on. Without it login behaves
// as it did before #688; main.go always wires it.
func (uc *LoginUseCase) WithAttemptLimits(store LoginAttemptStore) *LoginUseCase {
uc.attempts = store
return uc
}

// lockedFor reports how long the address stays locked, 0 when it is not.
//
// A store error lets the attempt through. The store already falls back to
// per-instance memory when Redis fails, so an error here is a bug, and turning
// it into a lockout of every account would be the worse failure.
func (uc *LoginUseCase) lockedFor(ctx context.Context, email string) time.Duration {
if uc.attempts == nil {
return 0
}
left, err := uc.attempts.LockedFor(ctx, loginLockKey(email))
if err != nil {
log.Printf("login: lock check failed: %v", err)
return 0
}
return left
}

// recordFailure counts one bad-credential refusal and locks the address when
// the count reaches the limit. The attempt that reaches it is still answered
// as a plain failure: it was evaluated. The next one is refused.
func (uc *LoginUseCase) recordFailure(ctx context.Context, email string) {
if uc.attempts == nil {
return
}
n, err := uc.attempts.Increment(ctx, loginFailKey(email), LoginFailureWindow)
if err != nil {
log.Printf("login: failure count not recorded: %v", err)
return
}
if n < LoginMaxFailuresPerAddress {
return
}
if _, err := uc.attempts.Lock(ctx, loginLockKey(email), LoginLockDuration); err != nil {
log.Printf("login: lock not set: %v", err)
return
}
// The lock carries the refusal now; a fresh count starts after it.
_ = uc.attempts.Delete(ctx, loginFailKey(email))
}

// clearFailures forgets the failures on an address after a success.
func (uc *LoginUseCase) clearFailures(ctx context.Context, email string) {
if uc.attempts == nil {
return
}
_ = uc.attempts.Delete(ctx, loginFailKey(email))
}

// WithLoginLockClearer lets a completed password reset end a sign-in lock on
// the account at once. The reset proves control of the mailbox, which is what
// the owner of an address someone else is hammering needs to get back in.
func (uc *ConfirmPasswordResetUseCase) WithLoginLockClearer(store LoginAttemptStore) *ConfirmPasswordResetUseCase {
uc.loginAttempts = store
return uc
}

func (uc *ConfirmPasswordResetUseCase) clearLoginLock(ctx context.Context, email string) {
if uc.loginAttempts == nil {
return
}
_ = uc.loginAttempts.Delete(ctx, loginLockKey(email))
_ = uc.loginAttempts.Delete(ctx, loginFailKey(email))
}
Loading
Loading