# frozen-string-literal: true
module Rodauth
Feature.define(:lockout, :Lockout) do
depends :login, :email_base
loaded_templates %w'unlock-account-request unlock-account password-field unlock-account-email'
view 'unlock-account-request', 'Request Account Unlock', 'unlock_account_request'
view 'unlock-account', 'Unlock Account', 'unlock_account'
before 'unlock_account'
before 'unlock_account_request'
after 'unlock_account'
after 'unlock_account_request'
after 'account_lockout'
additional_form_tags 'unlock_account'
additional_form_tags 'unlock_account_request'
button 'Unlock Account', 'unlock_account'
button 'Request Account Unlock', 'unlock_account_request'
error_flash "There was an error unlocking your account", 'unlock_account'
error_flash "This account is currently locked out and cannot be logged in to", "login_lockout"
error_flash "An email has recently been sent to you with a link to unlock the account", 'unlock_account_email_recently_sent'
error_flash "There was an error unlocking your account: invalid or expired unlock account key", 'no_matching_unlock_account_key'
notice_flash "Your account has been unlocked", 'unlock_account'
notice_flash "An email has been sent to you with a link to unlock your account", 'unlock_account_request'
redirect :unlock_account
redirect(:unlock_account_request){default_post_email_redirect}
redirect(:unlock_account_email_recently_sent){default_post_email_redirect}
email :unlock_account, 'Unlock Account'
auth_value_method :unlock_account_autologin?, true
auth_value_method :max_invalid_logins, 100
auth_value_method :account_login_failures_table, :account_login_failures
auth_value_method :account_login_failures_id_column, :id
auth_value_method :account_login_failures_number_column, :number
auth_value_method :account_lockouts_table, :account_lockouts
auth_value_method :account_lockouts_id_column, :id
auth_value_method :account_lockouts_key_column, :key
auth_value_method :account_lockouts_email_last_sent_column, :email_last_sent
auth_value_method :account_lockouts_deadline_column, :deadline
auth_value_method :account_lockouts_deadline_interval, {:days=>1}.freeze
translatable_method :unlock_account_explanatory_text, '<p>This account is currently locked out. You can unlock the account:</p>'
translatable_method :unlock_account_request_explanatory_text, '<p>This account is currently locked out. You can request that the account be unlocked:</p>'
auth_value_method :unlock_account_key_param, 'key'
auth_value_method :unlock_account_requires_password?, false
auth_value_method :unlock_account_skip_resend_email_within, 300
session_key :unlock_account_session_key, :unlock_account_key
auth_methods(
:clear_invalid_login_attempts,
:generate_unlock_account_key,
:get_unlock_account_key,
:get_unlock_account_email_last_sent,
:invalid_login_attempted,
:locked_out?,
:set_unlock_account_email_last_sent,
:unlock_account_email_link,
:unlock_account,
:unlock_account_key
)
auth_private_methods :account_from_unlock_key
internal_request_method(:lock_account)
internal_request_method(:unlock_account_request)
internal_request_method(:unlock_account)
route(:unlock_account_request) do |r|
check_already_logged_in
before_unlock_account_request_route
r.post do
if account_from_login(param(login_param)) && get_unlock_account_key
if unlock_account_email_recently_sent?
set_redirect_error_flash unlock_account_email_recently_sent_error_flash
redirect unlock_account_email_recently_sent_redirect
end
@unlock_account_key_value = get_unlock_account_key
transaction do
before_unlock_account_request
set_unlock_account_email_last_sent
send_unlock_account_email
after_unlock_account_request
end
set_notice_flash unlock_account_request_notice_flash
else
set_redirect_error_status(no_matching_login_error_status)
set_error_reason :no_matching_login
set_redirect_error_flash no_matching_login_message.to_s.capitalize
end
redirect unlock_account_request_redirect
end
end
route(:unlock_account) do |r|
check_already_logged_in
before_unlock_account_route
r.get do
if key = param_or_nil(unlock_account_key_param)
set_session_value(unlock_account_session_key, key)
redirect(r.path)
end
if key = session[unlock_account_session_key]
if account_from_unlock_key(key)
unlock_account_view
else
remove_session_value(unlock_account_session_key)
set_redirect_error_flash no_matching_unlock_account_key_error_flash
redirect require_login_redirect
end
end
end
r.post do
key = session[unlock_account_session_key] || param(unlock_account_key_param)
unless account_from_unlock_key(key)
set_redirect_error_status invalid_key_error_status
set_error_reason :invalid_unlock_account_key
set_redirect_error_flash no_matching_unlock_account_key_error_flash
redirect unlock_account_request_redirect
end
if !unlock_account_requires_password? || password_match?(param(password_param))
transaction do
before_unlock_account
unlock_account
after_unlock_account
if unlock_account_autologin?
autologin_session('unlock_account')
end
end
remove_session_value(unlock_account_session_key)
set_notice_flash unlock_account_notice_flash
redirect unlock_account_redirect
else
set_response_error_reason_status(:invalid_password, invalid_password_error_status)
set_field_error(password_param, invalid_password_message)
set_error_flash unlock_account_error_flash
unlock_account_view
end
end
end
def locked_out?
if t = convert_timestamp(account_lockouts_ds.get(account_lockouts_deadline_column))
if Time.now < t
true
else
unlock_account
false
end
else
false
end
end
def unlock_account
transaction do
remove_lockout_metadata
end
end
def clear_invalid_login_attempts
unlock_account
end
def _setup_account_lockouts_hash(account_id, key)
hash = {account_lockouts_id_column=>account_id, account_lockouts_key_column=>key}
set_deadline_value(hash, account_lockouts_deadline_column, account_lockouts_deadline_interval)
hash
end
def invalid_login_attempted
ds = account_login_failures_ds.
where(account_login_failures_id_column=>account_id)
number = if db.database_type == :postgres
ds.returning(account_login_failures_number_column).
with_sql(:update_sql, account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1).
single_value
else
# :nocov:
if ds.update(account_login_failures_number_column=>Sequel.expr(account_login_failures_number_column)+1) > 0
ds.get(account_login_failures_number_column)
end
# :nocov:
end
unless number
# Ignoring the violation is safe here. It may allow slightly more than max_invalid_logins invalid logins before
# lockout, but allowing a few extra is OK if the race is lost.
ignore_uniqueness_violation{account_login_failures_ds.insert(account_login_failures_id_column=>account_id)}
number = 1
end
if number >= max_invalid_logins
@unlock_account_key_value = generate_unlock_account_key
hash = _setup_account_lockouts_hash(account_id, unlock_account_key_value)
if e = raised_uniqueness_violation{account_lockouts_ds.insert(hash)}
# If inserting into the lockout table raises a violation, we should just be able to pull the already inserted
# key out of it. If that doesn't return a valid key, we should reraise the error.
raise e unless @unlock_account_key_value = account_lockouts_ds.get(account_lockouts_key_column)
after_account_lockout
show_lockout_page
else
after_account_lockout
e
end
end
end
def get_unlock_account_key
account_lockouts_ds.get(account_lockouts_key_column)
end
def account_from_unlock_key(key)
@account = _account_from_unlock_key(key)
end
def unlock_account_email_link
token_link(unlock_account_route, unlock_account_key_param, unlock_account_key_value)
end
def get_unlock_account_email_last_sent
if column = account_lockouts_email_last_sent_column
if ts = account_lockouts_ds.get(column)
convert_timestamp(ts)
end
end
end
def set_unlock_account_email_last_sent
account_lockouts_ds.update(account_lockouts_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if account_lockouts_email_last_sent_column
end
private
attr_reader :unlock_account_key_value
def before_login_attempt
if locked_out?
show_lockout_page
end
super
end
def after_login
clear_invalid_login_attempts
super
end
def after_login_failure
invalid_login_attempted
super
end
def after_close_account
remove_lockout_metadata
super if defined?(super)
end
def generate_unlock_account_key
random_key
end
def remove_lockout_metadata
account_login_failures_ds.delete
account_lockouts_ds.delete
end
def show_lockout_page
set_response_error_reason_status(:account_locked_out, lockout_error_status)
set_error_flash login_lockout_error_flash
return_response unlock_account_request_view
end
def unlock_account_email_recently_sent?
(email_last_sent = get_unlock_account_email_last_sent) && (Time.now - email_last_sent < unlock_account_skip_resend_email_within)
end
def use_date_arithmetic?
super || db.database_type == :mysql
end
def account_login_failures_ds
db[account_login_failures_table].where(account_login_failures_id_column=>account_id)
end
def account_lockouts_ds(id=account_id)
db[account_lockouts_table].where(account_lockouts_id_column=>id)
end
def _account_from_unlock_key(token)
account_from_key(token){|id| account_lockouts_ds(id).get(account_lockouts_key_column)}
end
end
end