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

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.

Returns:

  • (Boolean)


262
263
264
# File 'lib/concerns_on_rails/models/lockable.rb', line 262

def access_locked?
  self[self.class.lockable_locked_at_field].present? && !lock_expired?
end

#after_lockObject



189
# File 'lib/concerns_on_rails/models/lockable.rb', line 189

def after_lock; end

#after_unlockObject



191
# File 'lib/concerns_on_rails/models/lockable.rb', line 191

def after_unlock; end

#attempts_remainingObject

Failures left before auto-lock (never negative); nil when max_attempts is nil (counting without auto-lock).



290
291
292
293
294
295
# File 'lib/concerns_on_rails/models/lockable.rb', line 290

def attempts_remaining
  max = self.class.lockable_max_attempts
  return nil unless max

  [max - lockable_current_attempts, 0].max
end

#before_lockObject

---- lifecycle hooks (override in the model) ---- after_lock is the place for "your account has been locked" emails.



188
# File 'lib/concerns_on_rails/models/lockable.rb', line 188

def before_lock; end

#before_unlockObject



190
# File 'lib/concerns_on_rails/models/lockable.rb', line 190

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.



221
222
223
224
225
226
227
228
229
230
231
# File 'lib/concerns_on_rails/models/lockable.rb', line 221

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.

Returns:

  • (Boolean)


269
270
271
272
273
274
275
276
277
# File 'lib/concerns_on_rails/models/lockable.rb', line 269

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_atObject

When the current lock lapses, or nil (not locked, or manual-only).



280
281
282
283
284
285
286
# File 'lib/concerns_on_rails/models/lockable.rb', line 280

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.



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/concerns_on_rails/models/lockable.rb', line 200

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.



252
253
254
255
256
257
258
# File 'lib/concerns_on_rails/models/lockable.rb', line 252

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.



235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/concerns_on_rails/models/lockable.rb', line 235

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