Module: OpenLoam::AuthThrottle

Defined in:
lib/open_loam/auth_throttle.rb

Overview

Rate-limiting + lockout for authentication, to blunt online brute-force of passwords and (especially) 6-digit TOTP codes. A DB-backed counter keyed by the submitted identifier: after N failures within a window, the identifier is locked; a success clears the counter.

OpenLoam::AuthThrottle.locked?(email)                      # refuse if true
OpenLoam::AuthThrottle.record_failure(email, kind: "password", ip: request.ip)
OpenLoam::AuthThrottle.clear(email, kind: "password")      # on success

PER-IDENTIFIER is the primary defense (an attacker targets one account / code). A per-ip throttle to blunt spraying across accounts is a clean addition behind the same store (record_failure takes an ip) but not wired by default — noted as a roadmap knob. Rack::Attack is the PRODUCTION tool (needs a cache store + a gem); this DB counter is the portable, single-process-correct prototype.

Thresholds come from OpenLoam::Configs (per-tenant-overridable, but read globally here at the auth layer with sane defaults):

security.max_auth_attempts   (default 10)
security.auth_window_minutes (default 15) 

Class Method Summary collapse

Class Method Details

.attempts(identifier) ⇒ Object



83
84
85
# File 'lib/open_loam/auth_throttle.rb', line 83

def attempts(identifier)
  OpenLoam::AuthAttempt.where(identifier: normalize(identifier))
end

.clear(identifier, kind: nil) ⇒ Object

Reset the counter — call on a SUCCESSFUL auth so a legitimate user who eventually gets in isn't left throttled.

Pass kind: — clearing every kind on one factor's success lets an attacker holding the password reset the TOTP counter before each guess.



67
68
69
70
71
# File 'lib/open_loam/auth_throttle.rb', line 67

def clear(identifier, kind: nil)
  scope = attempts(identifier)
  scope = scope.where(kind: Array(kind).map(&:to_s)) if kind
  scope.delete_all
end

.locked?(identifier, kind: nil) ⇒ Boolean

Locked if there are >= max failures within the window. Old attempts age out of the window automatically (the window query IS the expiry — no reaper).

Both auth call sites ask WITHOUT a kind on purpose: failures on either factor lock both, so grinding TOTP also costs the attacker the password form.

Returns:

  • (Boolean)


52
53
54
# File 'lib/open_loam/auth_throttle.rb', line 52

def locked?(identifier, kind: nil)
  recent_failures(identifier, kind: kind) >= max_attempts
end

.lockout ⇒ Object



34
35
36
# File 'lib/open_loam/auth_throttle.rb', line 34

def lockout
  OpenLoam::Configs.get("security.auth_lockout_minutes", default: 15).to_i.minutes
end

.max_attempts ⇒ Object



26
27
28
# File 'lib/open_loam/auth_throttle.rb', line 26

def max_attempts
  OpenLoam::Configs.get("security.max_auth_attempts", default: 10).to_i
end

.normalize(identifier) ⇒ Object



87
88
89
# File 'lib/open_loam/auth_throttle.rb', line 87

def normalize(identifier)
  identifier.to_s.strip.downcase
end

.recent_failures(identifier, kind: nil) ⇒ Object



56
57
58
59
60
# File 'lib/open_loam/auth_throttle.rb', line 56

def recent_failures(identifier, kind: nil)
  scope = attempts(identifier).where("created_at > ?", window.ago)
  scope = scope.where(kind: kind.to_s) if kind
  scope.count
end

.record_failure(identifier, kind:, ip: nil) ⇒ Object

Log a failed attempt. Recorded for ANY submitted identifier — existing or not — so a lockout can never become an account-existence oracle.



40
41
42
43
44
45
# File 'lib/open_loam/auth_throttle.rb', line 40

def record_failure(identifier, kind:, ip: nil)
  identifier = normalize(identifier)
  return if identifier.blank?

  OpenLoam::AuthAttempt.create!(identifier: identifier, kind: kind.to_s, ip: ip)
end

.remaining_lockout(identifier) ⇒ Object

Seconds until the identifier unlocks (for the "try again in N" message), or 0 when not locked.



75
76
77
78
79
80
81
# File 'lib/open_loam/auth_throttle.rb', line 75

def remaining_lockout(identifier)
  last = attempts(identifier).maximum(:created_at)
  return 0 unless last

  seconds = (last + [ window, lockout ].max - Time.current).to_i
  seconds.positive? ? seconds : 0
end

.window ⇒ Object



30
31
32
# File 'lib/open_loam/auth_throttle.rb', line 30

def window
  OpenLoam::Configs.get("security.auth_window_minutes", default: 15).to_i.minutes
end