File: lockout.rb

package info (click to toggle)
ruby-rodauth 2.42.0-2
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 812 kB
  • sloc: ruby: 7,524; javascript: 100; makefile: 4
file content (307 lines) | stat: -rw-r--r-- 10,957 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# 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
    response :unlock_account
    response :unlock_account_request
    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(login_param_value) && 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

          unlock_account_request_response
        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
          redirect unlock_account_request_redirect
        end
      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]) && 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

      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
            clear_tokens(:unlock_account)
            after_unlock_account
            if unlock_account_autologin?
              autologin_session('unlock_account')
            end
          end

          remove_session_value(unlock_account_session_key)
          unlock_account_response
        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

    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 clear_tokens(reason)
      super
      account_lockouts_ds.update(account_lockouts_key_column => generate_unlock_account_key)
    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 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