Module: ConcernsOnRails::Models::Lockable
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/concerns_on_rails/models/lockable.rb
Overview
Failed-attempt tracking + account lockout (“Devise lockable-lite”) for apps rolling their own authentication (Rails 8 generator, has_secure_password) — which ships no brute-force protection at all. Two columns on the model’s own table, no tokens, no mailers.
class User < ApplicationRecord
include ConcernsOnRails::Lockable
lockable_by max_attempts: 5, unlock_in: 15.minutes
# lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
# prefix: :account # => .account_locked / .account_unlocked
end
user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
user.access_locked? # true while locked (expires after unlock_in)
user.attempts_remaining # for "3 attempts remaining" flash messages
user.reset_failed_attempts! # call on successful login
user.lock_access! / user.unlock_access!
User.locked / User.unlocked # expiry-aware scopes
Notes:
* `unlock_in: nil` (the default) means locked until unlock_access! is
called; with a duration, the lock lapses by itself. Expiry is lazy —
readers and scopes treat a stale `locked_at` as unlocked but never
write — so the column is cleared on the next unlock_access! or
register_failed_attempt! (quietly there: no unlock hooks fire from a
failed login). The expiry instant itself counts as unlocked.
* register_failed_attempt! increments with update_counters — a single
SQL-side `COALESCE(attempts, 0) + 1`, so concurrent failures never
lose updates (in-Ruby increment! is read-modify-write before Rails
5.2) and a NULL counter needs no column default. While the account is
locked it stops counting and returns the current count unchanged.
Two requests crossing the threshold at the same instant may each fire
after_lock once (same property as Devise).
* lock_access!/unlock_access! persist via update_columns: validations
and AR callbacks are bypassed on purpose, so an otherwise-invalid
record can still be locked. That also skips updated_at and means a
coexisting Auditable will not record the change. Hooks (before/
after_lock, before/after_unlock) run in a transaction — a raising
hook rolls the write back. reset_failed_attempts! fires no hooks.
* All bang methods raise ArgumentError on unsaved records.
* Reach for Devise's lockable when you need unlock tokens, unlock
emails, or per-strategy unlocks.
Defined Under Namespace
Modules: ClassMethods
Constant Summary collapse
- LABEL =
"ConcernsOnRails::Models::Lockable".freeze
- DEFAULT_ATTEMPTS_FIELD =
:failed_attempts- DEFAULT_LOCKED_AT_FIELD =
:locked_at- DEFAULT_MAX_ATTEMPTS =
5
Instance Method Summary collapse
-
#access_locked? ⇒ Boolean
Locked right now? Lazy expiry: a stale locked_at reads as unlocked but is never cleared here — readers stay side-effect free.
- #after_lock ⇒ Object
- #after_unlock ⇒ Object
-
#attempts_remaining ⇒ Object
Failures left before auto-lock (never negative); nil when max_attempts is nil (counting without auto-lock).
-
#before_lock ⇒ Object
—- lifecycle hooks (override in the model) —- after_lock is the place for “your account has been locked” emails.
- #before_unlock ⇒ Object
-
#lock_access! ⇒ Object
Lock now (update_columns — no validations/callbacks).
-
#lock_expired? ⇒ Boolean
Was locked, and the unlock_in window has fully elapsed.
-
#lock_expires_at ⇒ Object
When the current lock lapses, or nil (not locked, or manual-only).
-
#register_failed_attempt! ⇒ Object
Record one failed authentication attempt and auto-lock at the threshold.
-
#reset_failed_attempts! ⇒ Object
Successful-login path: zero the counter, leave any lock untouched, fire no hooks.
-
#unlock_access! ⇒ Object
Clear the lock and zero the counter in one write.
Instance Method Details
#access_locked? ⇒ Boolean
Locked right now? Lazy expiry: a stale locked_at reads as unlocked but is never cleared here — readers stay side-effect free.
220 221 222 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 220 def access_locked? self[self.class.lockable_locked_at_field].present? && !lock_expired? end |
#after_lock ⇒ Object
147 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 147 def after_lock; end |
#after_unlock ⇒ Object
149 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 149 def after_unlock; end |
#attempts_remaining ⇒ Object
Failures left before auto-lock (never negative); nil when max_attempts is nil (counting without auto-lock).
248 249 250 251 252 253 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 248 def attempts_remaining max = self.class.lockable_max_attempts return nil unless max [max - lockable_current_attempts, 0].max end |
#before_lock ⇒ Object
—- lifecycle hooks (override in the model) —- after_lock is the place for “your account has been locked” emails.
146 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 146 def before_lock; end |
#before_unlock ⇒ Object
148 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 148 def before_unlock; end |
#lock_access! ⇒ Object
Lock now (update_columns — no validations/callbacks). Idempotent while locked; an expired lock is re-locked with a fresh timestamp. Returns true, or false when a hook aborted the write via ActiveRecord::Rollback.
179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 179 def lock_access! lockable_guard_persisted!("lock_access!") return true if access_locked? field = self.class.lockable_locked_at_field lockable_write_with_hooks(field => self[field]) do before_lock update_columns(field => Time.zone.now) after_lock end end |
#lock_expired? ⇒ Boolean
Was locked, and the unlock_in window has fully elapsed. Always false when unlock_in is nil (manual unlock only). The boundary instant counts as expired (= unlocked), matching the scopes.
227 228 229 230 231 232 233 234 235 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 227 def lock_expired? unlock_in = self.class.lockable_unlock_in return false unless unlock_in locked_at = self[self.class.lockable_locked_at_field] return false if locked_at.nil? locked_at <= Time.zone.now - unlock_in end |
#lock_expires_at ⇒ Object
When the current lock lapses, or nil (not locked, or manual-only).
238 239 240 241 242 243 244 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 238 def lock_expires_at unlock_in = self.class.lockable_unlock_in locked_at = self[self.class.lockable_locked_at_field] return nil if unlock_in.nil? || locked_at.nil? locked_at + unlock_in end |
#register_failed_attempt! ⇒ Object
Record one failed authentication attempt and auto-lock at the threshold. Returns the fresh post-increment count (handy for “N attempts remaining” messaging); check access_locked? for the lock decision. While locked it neither counts nor re-locks — that branch returns the current in-memory count unchanged.
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 158 def register_failed_attempt! lockable_guard_persisted!("register_failed_attempt!") return lockable_current_attempts if access_locked? # A lapsed lock is cleared quietly — this is a *failed* login, so # firing unlock hooks ("account unlocked" notifications) would be # wrong. The failure below then counts as attempt 1 of the new window. lockable_clear_expired_lock! if lock_expired? self.class.update_counters(id, self.class.lockable_attempts_field => 1) fresh = lockable_fresh_attempts_count lockable_sync_attempts(fresh) max = self.class.lockable_max_attempts lock_access! if max && fresh >= max fresh end |
#reset_failed_attempts! ⇒ Object
Successful-login path: zero the counter, leave any lock untouched, fire no hooks. (Unlocking is a separate, deliberate act.) The already-zero short-circuit saves a write per successful login.
210 211 212 213 214 215 216 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 210 def reset_failed_attempts! lockable_guard_persisted!("reset_failed_attempts!") return true if lockable_current_attempts.zero? update_column(self.class.lockable_attempts_field, 0) true end |
#unlock_access! ⇒ Object
Clear the lock and zero the counter in one write. Fires unlock hooks. Returns true, or false when a hook aborted via ActiveRecord::Rollback.
193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/concerns_on_rails/models/lockable.rb', line 193 def unlock_access! lockable_guard_persisted!("unlock_access!") locked_field = self.class.lockable_locked_at_field return true if self[locked_field].nil? attempts_field = self.class.lockable_attempts_field lockable_write_with_hooks(locked_field => self[locked_field], attempts_field => self[attempts_field]) do before_unlock update_columns(locked_field => nil, attempts_field => 0) after_unlock end end |