File: otp_unlock.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 (257 lines) | stat: -rw-r--r-- 9,506 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
# frozen-string-literal: true

module Rodauth
  Feature.define(:otp_unlock, :OtpUnlock) do
    depends :otp

    before 'otp_unlock_attempt'
    after 'otp_unlock_auth_success'
    after 'otp_unlock_auth_failure'
    after 'otp_unlock_not_yet_available'

    error_flash "TOTP authentication is not currently locked out", 'otp_unlock_not_locked_out'
    error_flash "TOTP invalid authentication", 'otp_unlock_auth_failure'
    error_flash "Deadline past for unlocking TOTP authentication", 'otp_unlock_auth_deadline_passed'
    error_flash "TOTP unlock attempt not yet available", 'otp_unlock_auth_not_yet_available'

    notice_flash "TOTP authentication unlocked", 'otp_unlocked'
    notice_flash "TOTP successful authentication, more successful authentication needed to unlock", 'otp_unlock_auth_success'

    redirect :otp_unlock_not_locked_out
    redirect :otp_unlocked

    additional_form_tags

    button 'Authenticate Using TOTP to Unlock', 'otp_unlock'

    auth_value_method :otp_unlock_auth_deadline_passed_error_status, 403
    auth_value_method :otp_unlock_auth_failure_cooldown_seconds, 900
    auth_value_method :otp_unlock_auth_failure_error_status, 403
    auth_value_method :otp_unlock_auth_not_yet_available_error_status, 403
    auth_value_method :otp_unlock_auths_required, 3
    auth_value_method :otp_unlock_deadline_seconds, 900
    auth_value_method :otp_unlock_id_column, :id
    auth_value_method :otp_unlock_next_auth_attempt_after_column, :next_auth_attempt_after
    auth_value_method :otp_unlock_not_locked_out_error_status, 403
    auth_value_method :otp_unlock_num_successes_column, :num_successes
    auth_value_method :otp_unlock_table, :account_otp_unlocks

    translatable_method :otp_unlock_consecutive_successes_label, 'Consecutive successful authentications'
    translatable_method :otp_unlock_form_footer, ''
    translatable_method :otp_unlock_next_auth_attempt_label, 'Can attempt next authentication after'
    translatable_method :otp_unlock_next_auth_attempt_refresh_label, 'Page will automatically refresh when authentication is possible.'
    translatable_method :otp_unlock_next_auth_deadline_label, 'Deadline for next authentication'
    translatable_method :otp_unlock_required_consecutive_successes_label, 'Required consecutive successful authentications to unlock'

    loaded_templates %w'otp-unlock otp-unlock-not-available'
    view 'otp-unlock', 'Unlock TOTP Authentication', 'otp_unlock'
    view 'otp-unlock-not-available', 'Must Wait to Unlock TOTP Authentication', 'otp_unlock_not_available'

    auth_methods(
      :otp_unlock_auth_failure,
      :otp_unlock_auth_success,
      :otp_unlock_available?,
      :otp_unlock_deadline_passed?,
      :otp_unlock_not_available_set_refresh_header,
      :otp_unlock_refresh_tag,
    )

    route(:otp_unlock) do |r|
      require_login
      require_account_session
      require_otp_setup

      unless otp_locked_out?
        set_response_error_reason_status(:otp_not_locked_out, otp_unlock_not_locked_out_error_status)
        set_redirect_error_flash otp_unlock_not_locked_out_error_flash
        redirect otp_unlock_not_locked_out_redirect
      end

      before_otp_unlock_route

      r.get do
        if otp_unlock_available?
          otp_unlock_view
        else
          otp_unlock_not_available_set_refresh_header
          otp_unlock_not_available_view
        end
      end

      r.post do
        db.transaction do
          if otp_unlock_deadline_passed?
            set_response_error_reason_status(:otp_unlock_deadline_passed, otp_unlock_auth_deadline_passed_error_status)
            set_redirect_error_flash otp_unlock_auth_deadline_passed_error_flash
          elsif !otp_unlock_available?
            after_otp_unlock_not_yet_available
            set_response_error_reason_status(:otp_unlock_not_yet_available, otp_unlock_auth_not_yet_available_error_status)
            set_redirect_error_flash otp_unlock_auth_not_yet_available_error_flash
          else
            before_otp_unlock_attempt
            if otp_valid_code?(param(otp_auth_param))
              otp_unlock_auth_success
              after_otp_unlock_auth_success

              unless otp_locked_out?
                set_notice_flash otp_unlocked_notice_flash
                redirect otp_unlocked_redirect
              end

              set_notice_flash otp_unlock_auth_success_notice_flash
            else
              otp_unlock_auth_failure
              after_otp_unlock_auth_failure
              set_response_error_reason_status(:otp_unlock_auth_failure, otp_unlock_auth_failure_error_status)
              set_redirect_error_flash otp_unlock_auth_failure_error_flash
            end
          end
        end

        redirect request.path
      end
    end

    def otp_unlock_available?
      if otp_unlock_data
        next_auth_attempt_after = otp_unlock_next_auth_attempt_after
        current_timestamp = Time.now

        if (next_auth_attempt_after < current_timestamp - otp_unlock_deadline_seconds)
          # Unlock process not fully completed within deadline, reset process
          otp_unlock_reset
          true
        else
          if next_auth_attempt_after > current_timestamp
            # If next auth attempt after timestamp is in the future, that means the next
            # unlock attempt cannot happen until then.
            false 
          else
            if otp_unlock_num_successes == 0
              # 0 value indicates previous attempt was a failure. Since failure cooldown
              # period has passed, reset process so user gets full deadline period
              otp_unlock_reset
            end
            true
          end
        end
      else
        # No row means no unlock attempts yet (or previous attempt was more than the
        # deadline account, so unlocking is available
        true
      end
    end

    def otp_unlock_auth_failure
      h = {
        otp_unlock_num_successes_column=>0,
        otp_unlock_next_auth_attempt_after_column=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :seconds=>otp_unlock_auth_failure_cooldown_seconds)
      }

      if otp_unlock_ds.update(h) == 0
        h[otp_unlock_id_column] = session_value

        # If row already exists when inserting, no need to do anything
        raises_uniqueness_violation?{otp_unlock_ds.insert(h)}
      end
    end

    def otp_unlock_auth_success
      deadline = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :seconds=>otp_unlock_success_cooldown_seconds)

      # Add WHERE to avoid possible race condition when multiple unlock auth requests
      # are sent at the same time (only the first should increment num successes).
      if otp_unlock_ds.
          where(Sequel[otp_unlock_next_auth_attempt_after_column] < Sequel::CURRENT_TIMESTAMP).
          update(
            otp_unlock_num_successes_column=>Sequel[otp_unlock_num_successes_column]+1,
            otp_unlock_next_auth_attempt_after_column=>deadline
          ) == 0

        # Ignore uniqueness errors when inserting after a failed update, 
        # which could be caused due to the race condition mentioned above.
        raises_uniqueness_violation? do
          otp_unlock_ds.insert(
            otp_unlock_id_column=>session_value,
            otp_unlock_next_auth_attempt_after_column=>deadline
          )
        end
      end

      @otp_unlock_data = nil
      # :nocov:
      if otp_unlock_data
      # :nocov:
        if otp_unlock_num_successes >= otp_unlock_auths_required
          # At least the requisite number of consecutive successful unlock
          # authentications. Unlock OTP authentication.
          otp_key_ds.update(otp_keys_failures_column => 0)

          # Remove OTP unlock metadata when unlocking OTP authentication
          otp_unlock_reset
        # else
        #  # Still need additional consecutive successful unlock attempts.
        end
      # else
      #  # if row isn't available, probably the process was reset during this,
      #  # and it's safe to do nothing in that case.
      end
    end

    def otp_unlock_deadline_passed?
      otp_unlock_data ? (otp_unlock_next_auth_attempt_after < Time.now - otp_unlock_deadline_seconds) : false
    end

    def otp_unlock_refresh_tag
      # RODAUTH3: Remove
      "<meta http-equiv=\"refresh\" content=\"#{(otp_unlock_next_auth_attempt_after - Time.now).to_i + 1}\">"
    end

    def otp_lockout_redirect
      otp_unlock_path
    end

    def otp_unlock_next_auth_attempt_after
      if otp_unlock_data
        convert_timestamp(otp_unlock_data[otp_unlock_next_auth_attempt_after_column])
      else
        Time.now
      end
    end

    def otp_unlock_deadline
      otp_unlock_next_auth_attempt_after + otp_unlock_deadline_seconds
    end

    def otp_unlock_num_successes
      otp_unlock_data ? otp_unlock_data[otp_unlock_num_successes_column] : 0
    end

    def otp_unlock_not_available_set_refresh_header
      response.headers["refresh"] = ((otp_unlock_next_auth_attempt_after - Time.now).to_i + 1).to_s
    end

    private

    def show_otp_auth_link?
      super || (otp_exists? && otp_locked_out?)
    end

    def otp_unlock_data
      @otp_unlock_data ||= otp_unlock_ds.first
    end

    def otp_unlock_success_cooldown_seconds
      (_otp_interval+(otp_drift||0))*2
    end

    def otp_unlock_reset
      otp_unlock_ds.delete
      @otp_unlock_data = nil
    end

    def otp_unlock_ds
      db[otp_unlock_table].where(otp_unlock_id_column=>session_value)
    end
  end
end