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)


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_lockObject



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

def after_lock; end

#after_unlockObject



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

def after_unlock; end

#attempts_remainingObject

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_lockObject

—- 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_unlockObject



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.

Returns:

  • (Boolean)


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_atObject

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