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
- .attempts(identifier) ⇒ Object
-
.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.
-
.locked?(identifier, kind: nil) ⇒ Boolean
Locked if there are >= max failures within the window.
- .lockout ⇒ Object
- .max_attempts ⇒ Object
- .normalize(identifier) ⇒ Object
- .recent_failures(identifier, kind: nil) ⇒ Object
-
.record_failure(identifier, kind:, ip: nil) ⇒ Object
Log a failed attempt.
-
.remaining_lockout(identifier) ⇒ Object
Seconds until the identifier unlocks (for the "try again in N" message), or 0 when not locked.
- .window ⇒ Object
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.
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 |