add_auth 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +125 -0
- data/CODE_OF_CONDUCT.md +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +545 -0
- data/ROADMAP.md +244 -0
- data/SECURITY.md +75 -0
- data/app/controllers/add_auth/assets_controller.rb +54 -0
- data/app/controllers/add_auth/passkeys_controller.rb +144 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +81 -0
- data/app/controllers/add_auth/recoveries_controller.rb +50 -0
- data/app/controllers/add_auth/sessions_controller.rb +62 -0
- data/app/controllers/add_auth/sign_ins_controller.rb +65 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +42 -0
- data/app/javascript/controllers/.keep +0 -0
- data/app/jobs/add_auth/delivery_job.rb +31 -0
- data/app/jobs/add_auth/email_delivery_job.rb +15 -0
- data/app/jobs/add_auth/email_request_job.rb +21 -0
- data/app/jobs/add_auth/security_notification_job.rb +13 -0
- data/app/mailers/add_auth/security_mailer.rb +16 -0
- data/app/mailers/add_auth/sign_in_mailer.rb +36 -0
- data/app/models/concerns/.keep +0 -0
- data/app/views/.keep +0 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +17 -0
- data/app/views/add_auth/passkeys/_list.html.erb +32 -0
- data/app/views/add_auth/reauthentications/_check_email.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_confirmation.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_different_browser.html.erb +3 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +27 -0
- data/app/views/add_auth/reauthentications/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/recoveries/_check_email.html.erb +3 -0
- data/app/views/add_auth/recoveries/_confirmation.html.erb +9 -0
- data/app/views/add_auth/recoveries/_form.html.erb +11 -0
- data/app/views/add_auth/recoveries/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/security_mailer/notice.text.erb +3 -0
- data/app/views/add_auth/sessions/_list.html.erb +25 -0
- data/app/views/add_auth/sessions/_revoke_all.html.erb +18 -0
- data/app/views/add_auth/sessions/index.html.erb +12 -0
- data/app/views/add_auth/sign_in_mailer/link.text.erb +5 -0
- data/app/views/add_auth/sign_ins/_check_email.html.erb +5 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +9 -0
- data/app/views/add_auth/sign_ins/_different_browser.html.erb +3 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +34 -0
- data/app/views/add_auth/sign_ins/_invalid_link.html.erb +3 -0
- data/app/views/add_auth/sign_ins/show.html.erb +1 -0
- data/app/views/layouts/add_auth/authentication.html.erb +19 -0
- data/lib/add_auth/configuration.rb +61 -0
- data/lib/add_auth/core/access_policy.rb +55 -0
- data/lib/add_auth/core/browser_binding.rb +28 -0
- data/lib/add_auth/core/challenge/base.rb +100 -0
- data/lib/add_auth/core/challenge/http.rb +130 -0
- data/lib/add_auth/core/challenge/null.rb +21 -0
- data/lib/add_auth/core/challenge/recaptcha.rb +76 -0
- data/lib/add_auth/core/challenge/test.rb +32 -0
- data/lib/add_auth/core/challenge/turnstile.rb +46 -0
- data/lib/add_auth/core/delivery.rb +55 -0
- data/lib/add_auth/core/digest/base.rb +34 -0
- data/lib/add_auth/core/digest/hmac.rb +41 -0
- data/lib/add_auth/core/intake.rb +59 -0
- data/lib/add_auth/core/maintenance.rb +45 -0
- data/lib/add_auth/core/rate_limit.rb +30 -0
- data/lib/add_auth/core/security_events.rb +48 -0
- data/lib/add_auth/core/sessions.rb +287 -0
- data/lib/add_auth/core/step_up.rb +129 -0
- data/lib/add_auth/core/strategies/email_link.rb +201 -0
- data/lib/add_auth/core/strategies/passkey.rb +286 -0
- data/lib/add_auth/rails/authentication.rb +67 -0
- data/lib/add_auth/rails/authentication_pages.rb +66 -0
- data/lib/add_auth/rails/delivery_cipher.rb +33 -0
- data/lib/add_auth/rails/doctor.rb +154 -0
- data/lib/add_auth/rails/ejection.rb +138 -0
- data/lib/add_auth/rails/elevation.rb +35 -0
- data/lib/add_auth/rails/engine.rb +34 -0
- data/lib/add_auth/rails/password_entry.rb +34 -0
- data/lib/add_auth/rails/runtime.rb +216 -0
- data/lib/add_auth/rails/stores/account_lock.rb +40 -0
- data/lib/add_auth/rails/stores/delivery_state.rb +21 -0
- data/lib/add_auth/rails/stores/email_tokens.rb +92 -0
- data/lib/add_auth/rails/stores/maintenance.rb +46 -0
- data/lib/add_auth/rails/stores/passkeys.rb +63 -0
- data/lib/add_auth/rails/stores/security_events.rb +40 -0
- data/lib/add_auth/rails/stores/sessions.rb +62 -0
- data/lib/add_auth/rails/user_lifecycle.rb +35 -0
- data/lib/add_auth/result.rb +55 -0
- data/lib/add_auth/testing.rb +30 -0
- data/lib/add_auth/version.rb +5 -0
- data/lib/add_auth.rb +52 -0
- data/lib/generators/add_auth/challenge/challenge_generator.rb +48 -0
- data/lib/generators/add_auth/challenge/templates/recaptcha.rb.tt +28 -0
- data/lib/generators/add_auth/challenge/templates/turnstile.rb.tt +24 -0
- data/lib/generators/add_auth/controllers/controllers_generator.rb +14 -0
- data/lib/generators/add_auth/ejection.rb +18 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +56 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_binding.rb.tt +5 -0
- data/lib/generators/add_auth/email_link/templates/add_add_auth_email_delivery.rb.tt +10 -0
- data/lib/generators/add_auth/email_link/templates/add_auth.css +28 -0
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +129 -0
- data/lib/generators/add_auth/email_tokens/email_tokens_generator.rb +35 -0
- data/lib/generators/add_auth/email_tokens/templates/add_auth_sign_in_token.rb +4 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +18 -0
- data/lib/generators/add_auth/install/install_generator.rb +27 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +46 -0
- data/lib/generators/add_auth/javascript/javascript_generator.rb +14 -0
- data/lib/generators/add_auth/javascript/templates/application.js +2 -0
- data/lib/generators/add_auth/javascript/templates/codec.js +28 -0
- data/lib/generators/add_auth/javascript/templates/passkey.js +81 -0
- data/lib/generators/add_auth/mailer_views/mailer_views_generator.rb +14 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +32 -0
- data/lib/generators/add_auth/notifications/templates/add_auth_security_event.rb +4 -0
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +18 -0
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +75 -0
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +45 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_ceremony.rb +4 -0
- data/lib/generators/add_auth/passkeys/templates/add_auth_credential.rb +4 -0
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +79 -0
- data/lib/generators/add_auth/session_upgrade/templates/add_add_auth_elevation.rb.tt +12 -0
- data/lib/generators/add_auth/session_upgrade/templates/extend_sessions_for_add_auth.rb.tt +14 -0
- data/lib/generators/add_auth/step_up/step_up_generator.rb +44 -0
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +9 -0
- data/lib/generators/add_auth/views/views_generator.rb +16 -0
- data/lib/tasks/add_auth.rake +38 -0
- metadata +361 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Strategies
|
|
8
|
+
# Internal sign-in token lifecycle, NOT an HTTP endpoint. Run issuance
|
|
9
|
+
# behind a uniform asynchronous intake/rate-limit boundary. A nil return
|
|
10
|
+
# does not make account-dependent database work timing indistinguishable.
|
|
11
|
+
# Sign-in and reauthentication have distinct purposes. Store transactions
|
|
12
|
+
# enclose Core policy, proof consumption and the session finalizer.
|
|
13
|
+
class EmailLink
|
|
14
|
+
include Delivery
|
|
15
|
+
|
|
16
|
+
DEFAULT_TOKEN_LIFETIME = 20 * 60
|
|
17
|
+
TOKEN_BYTES = 32
|
|
18
|
+
TOKEN_PATTERN = /\A[A-Za-z0-9_-]{43}\z/
|
|
19
|
+
|
|
20
|
+
def initialize(store:, digest:, delivery_cipher:, eligible:, normalize_identifier:, identifier_for:,
|
|
21
|
+
clock: Time, random: SecureRandom, token_lifetime: DEFAULT_TOKEN_LIFETIME, same_browser: false, purpose: :sign_in, sessions: nil, policy: nil, proof_allowed: ->(_user) { true })
|
|
22
|
+
unless token_lifetime.is_a?(Numeric) && token_lifetime.finite? && token_lifetime.positive?
|
|
23
|
+
raise ArgumentError, "token_lifetime must be finite and positive"
|
|
24
|
+
end
|
|
25
|
+
@store, @digest, @cipher = store, digest, delivery_cipher
|
|
26
|
+
@eligible, @normalize, @identifier_for = eligible, normalize_identifier, identifier_for
|
|
27
|
+
@clock, @random, @token_lifetime = clock, random, token_lifetime
|
|
28
|
+
raise ArgumentError, "same_browser must be true or false" unless [true, false].include?(same_browser)
|
|
29
|
+
@purpose = purpose.to_s
|
|
30
|
+
raise ArgumentError, "unsupported email purpose" unless %w[sign_in reauthentication recovery].include?(@purpose)
|
|
31
|
+
if @purpose != "sign_in" && (!sessions || !policy)
|
|
32
|
+
raise ArgumentError, "reauthentication requires sessions and purpose policy"
|
|
33
|
+
end
|
|
34
|
+
@sessions, @policy, @proof_allowed = sessions, policy, proof_allowed
|
|
35
|
+
@same_browser = same_browser || @purpose == "reauthentication"
|
|
36
|
+
@binding = BrowserBinding.new(digest: digest)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def browser_digest(secret) = @same_browser ? @binding.digest(secret) : nil
|
|
40
|
+
|
|
41
|
+
def confirmation_page(preview)
|
|
42
|
+
return "invalid_link" unless preview
|
|
43
|
+
preview.fetch(:browser_matches) ? "confirmation" : "different_browser"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def issue(identifier:, requested_ip_address: nil, request_id: nil, browser_digest: nil, session_id: nil, session_digest: nil, authentication_purpose: nil)
|
|
47
|
+
return if @same_browser && (!browser_digest.is_a?(String) || browser_digest.empty?)
|
|
48
|
+
return nil unless identifier.is_a?(String) && identifier.valid_encoding? && identifier.bytesize.between?(1, 254)
|
|
49
|
+
|
|
50
|
+
normalized = @normalize.call(identifier)
|
|
51
|
+
return nil unless normalized.is_a?(String) && normalized.valid_encoding? && normalized.bytesize.between?(1, 254)
|
|
52
|
+
raw = @random.urlsafe_base64(TOKEN_BYTES)
|
|
53
|
+
raise AddAuth::Error, "random source returned an invalid token" unless valid_token?(raw)
|
|
54
|
+
digest = @digest.digest(raw)
|
|
55
|
+
at = @clock.now
|
|
56
|
+
expires_at = at + @token_lifetime
|
|
57
|
+
payload = @cipher.encrypt(token: raw, digest: digest, expires_at: expires_at)
|
|
58
|
+
@store.with_user(identifier: normalized) do |user|
|
|
59
|
+
next if request_id && @store.issued?(request_id: request_id)
|
|
60
|
+
if user && @eligible.call(user) == true && @proof_allowed.call(user) == true && @normalize.call(@identifier_for.call(user)) == normalized
|
|
61
|
+
context = {}
|
|
62
|
+
if @purpose == "reauthentication"
|
|
63
|
+
row = @store.session_for(user: user, id: session_id)
|
|
64
|
+
next unless row && row.token_digest == session_digest && @sessions.current_in_transaction(user: user, session: row) &&
|
|
65
|
+
@policy.reauthentication_rule_for(authentication_purpose) && @policy.methods_for(user: user, purpose: authentication_purpose).include?(:email_link)
|
|
66
|
+
context = {session_id: row.id, session_digest: row.token_digest, authentication_purpose: authentication_purpose.to_s}
|
|
67
|
+
end
|
|
68
|
+
@store.replace_pending(user: user, purpose: @purpose, **context, digest: digest, expires_at: expires_at,
|
|
69
|
+
created_at: at, delivery_payload: payload, identifier_digest: @digest.digest("identifier:#{normalized}"),
|
|
70
|
+
requested_ip_address: requested_ip_address, **(browser_digest ? {browser_digest: browser_digest} : {}), **(request_id ? {request_id: request_id} : {}))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The finalizer must persist the session in the same transaction; cookie
|
|
77
|
+
# writing/delivery happens only after this method successfully returns.
|
|
78
|
+
# Pass a trusted current_session snapshot here and as replacing: to the
|
|
79
|
+
# session finalizer so both accounts are locked during a browser switch.
|
|
80
|
+
def consume(token:, current_user_id: nil, current_session: nil, switch_account: false, browser_secret: nil)
|
|
81
|
+
raise ArgumentError, "use reauthenticate for this proof" if @purpose == "reauthentication"
|
|
82
|
+
raise ArgumentError, "a transactional session finalizer is required" unless block_given? || @purpose == "recovery"
|
|
83
|
+
return failure(:invalid_credentials) unless valid_token?(token)
|
|
84
|
+
|
|
85
|
+
current_user_id = current_session.user_id if current_session
|
|
86
|
+
@store.with_token(digest: @digest.digest(token), current_session: current_session) do |user, record|
|
|
87
|
+
reason = rejection(user, record)
|
|
88
|
+
reason ||= :invalid_credentials unless browser_matches?(record, browser_secret)
|
|
89
|
+
reason ||= :invalid_credentials if current_user_id && user && current_user_id != user.id && switch_account != true
|
|
90
|
+
if reason
|
|
91
|
+
failure(reason)
|
|
92
|
+
else
|
|
93
|
+
@store.consume(record: record, at: @clock.now)
|
|
94
|
+
grant = nil
|
|
95
|
+
session = @store.finalize_session(user: user) do |persist|
|
|
96
|
+
if @purpose == "recovery"
|
|
97
|
+
grant = recovery_session(user, record, persist, current_session)
|
|
98
|
+
grant.session
|
|
99
|
+
else
|
|
100
|
+
yield user, persist
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
AddAuth::Result.success(user: user, strategy: :email_link, session: session, grant: grant)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def reauthenticate(token:, session:, browser_secret:)
|
|
109
|
+
return failure(:invalid_credentials) unless @purpose == "reauthentication" && valid_token?(token)
|
|
110
|
+
@store.with_token(digest: @digest.digest(token)) do |user, record|
|
|
111
|
+
reason = rejection(user, record)
|
|
112
|
+
reason ||= :invalid_credentials unless browser_matches?(record, browser_secret)
|
|
113
|
+
reason ||= :elevation_required unless session && record && session.id == record.session_id &&
|
|
114
|
+
session.token_digest == record.session_digest && user && @sessions.current_in_transaction(user: user, session: session)
|
|
115
|
+
next failure(reason) if reason
|
|
116
|
+
evidence = StepUp::Evidence.new(user_id: user.id, session_id: session.id, method: :email_link,
|
|
117
|
+
verified_at: @clock.now, session_digest: session.token_digest, credential_version: record.identifier_digest)
|
|
118
|
+
result = @policy.authorize(user: user, session_id: session.id, purpose: record.authentication_purpose, evidence: evidence)
|
|
119
|
+
next result unless result.success?
|
|
120
|
+
rotated = @sessions.rotate_in_transaction(user: user, session: session, grant: result.credential)
|
|
121
|
+
next failure(:elevation_required) unless rotated
|
|
122
|
+
@store.consume(record: record, at: @clock.now)
|
|
123
|
+
Result.success(user: user, strategy: :step_up, session: rotated.session, credential: rotated)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Delivery workers fetch by digest/issuance identity, never raw job args.
|
|
128
|
+
# Repeated calls return the SAME token. A future worker must recheck before
|
|
129
|
+
# sending and tolerate an email becoming stale while already in transit.
|
|
130
|
+
def delivery_token(digest:)
|
|
131
|
+
@store.with_token(digest: digest) do |user, record|
|
|
132
|
+
unless rejection(user, record) || record.delivery_payload.nil?
|
|
133
|
+
token = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
|
|
134
|
+
token if valid_token?(token) && @digest.matches?(record.digest, token)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def preview(token:, browser_secret: nil)
|
|
140
|
+
return unless valid_token?(token)
|
|
141
|
+
@store.with_token(digest: @digest.digest(token)) do |user, record|
|
|
142
|
+
unless rejection(user, record)
|
|
143
|
+
address = @identifier_for.call(user)
|
|
144
|
+
local, domain = address.split("@", 2)
|
|
145
|
+
{user_id: user.id, masked_address: "#{local[0]}•••@#{domain}", browser_matches: browser_matches?(record, browser_secret)}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def recovery_session(user, record, persist, previous)
|
|
153
|
+
initial = @sessions.create_in_transaction(user: user, method: :email_link, persist: persist, replacing: previous)
|
|
154
|
+
raise AddAuth::Error, "recovery could not create a session" unless initial
|
|
155
|
+
evidence = StepUp::Evidence.new(user_id: user.id, session_id: initial.session.id, method: :email_link,
|
|
156
|
+
verified_at: @clock.now, session_digest: initial.session.token_digest, credential_version: record.identifier_digest)
|
|
157
|
+
proof = @policy.authorize(user: user, session_id: initial.session.id, purpose: :recover_passkeys, evidence: evidence)
|
|
158
|
+
raise AddAuth::Error, "recovery is not permitted" unless proof.success?
|
|
159
|
+
grant = @sessions.rotate_in_transaction(user: user, session: initial.session, grant: proof.credential)
|
|
160
|
+
raise AddAuth::Error, "recovery could not finalize" unless grant
|
|
161
|
+
# Preserve the transaction writer's exact newly created row identity.
|
|
162
|
+
grant.session = @store.refresh_session(initial.session)
|
|
163
|
+
grant
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def delivery_details(user, record)
|
|
167
|
+
raw = @cipher.decrypt(payload: record.delivery_payload, digest: record.digest)
|
|
168
|
+
{token: raw, recipient: @identifier_for.call(user)} if valid_token?(raw) && @digest.matches?(record.digest, raw)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def browser_matches?(record, secret)
|
|
172
|
+
stored = record.browser_digest if record&.respond_to?(:browser_digest)
|
|
173
|
+
return true if !@same_browser && stored.nil?
|
|
174
|
+
@binding.matches?(stored, secret)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def valid_token?(token)
|
|
178
|
+
token.is_a?(String) && token.bytesize == 43 && token.ascii_only? && TOKEN_PATTERN.match?(token)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def rejection(user, record)
|
|
182
|
+
return :invalid_credentials unless user && record && record.purpose == @purpose
|
|
183
|
+
return :disabled unless @eligible.call(user) == true && @proof_allowed.call(user) == true
|
|
184
|
+
identifier = @normalize.call(@identifier_for.call(user))
|
|
185
|
+
return :revoked_token unless @digest.matches?(record.identifier_digest, "identifier:#{identifier}")
|
|
186
|
+
if @purpose == "reauthentication"
|
|
187
|
+
row = @store.session_for(user: user, id: record.session_id)
|
|
188
|
+
return :elevation_required unless row && row.token_digest == record.session_digest &&
|
|
189
|
+
@sessions.current_in_transaction(user: user, session: row) &&
|
|
190
|
+
@policy.reauthentication_rule_for(record.authentication_purpose) && @policy.methods_for(user: user, purpose: record.authentication_purpose).include?(:email_link)
|
|
191
|
+
end
|
|
192
|
+
return :revoked_token if record.revoked_at
|
|
193
|
+
return :consumed_token if record.consumed_at
|
|
194
|
+
:expired_token if record.expires_at <= @clock.now
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def failure(reason) = AddAuth::Result.failure(reason: reason)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webauthn"
|
|
4
|
+
require "json"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "public_suffix"
|
|
8
|
+
|
|
9
|
+
module AddAuth
|
|
10
|
+
module Core
|
|
11
|
+
module Strategies
|
|
12
|
+
class Passkey
|
|
13
|
+
PURPOSE = :manage_passkeys
|
|
14
|
+
PAYLOAD_LIMIT = 65_536
|
|
15
|
+
Entry = Data.define(:id, :nickname, :created_at, :last_used_at, :backup_eligible, :backup_state)
|
|
16
|
+
|
|
17
|
+
def initialize(store:, sessions:, policy:, access_policy:, digest:, eligible:, rp_id:, origins:, name:,
|
|
18
|
+
notify:, limiter:, anonymous_limit: 1000, clock: Time, allow_localhost: false, support_url: nil, on_failure: ->(_reason) {})
|
|
19
|
+
@store, @sessions, @policy, @access, @digest, @eligible = store, sessions, policy, access_policy, digest, eligible
|
|
20
|
+
@clock, @notify, @support_url, @on_failure = clock, notify, support_url, on_failure
|
|
21
|
+
@limiter, @anonymous_limit = limiter, anonymous_limit
|
|
22
|
+
unless anonymous_limit.is_a?(Integer) && anonymous_limit.positive?
|
|
23
|
+
raise AddAuth::Error, "passkeys.anonymous_limit must be a positive integer"
|
|
24
|
+
end
|
|
25
|
+
raise AddAuth::Error, "support_url must be a safe local support page" if support_url && !Sessions.safe_return(support_url)
|
|
26
|
+
@binding = BrowserBinding.new(digest: digest)
|
|
27
|
+
validate_origins!(rp_id, origins, allow_localhost)
|
|
28
|
+
@rp = WebAuthn::RelyingParty.new(id: rp_id, name: name, allowed_origins: origins.dup,
|
|
29
|
+
encoding: :base64url, acceptable_attestation_types: ["None"], credential_options_timeout: 120_000)
|
|
30
|
+
@configuration_digest = digest.digest(JSON.generate([rp_id, origins.sort]))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def registration_options(user:, session:, browser_secret:)
|
|
34
|
+
@store.with_user(id: user&.id) do |account|
|
|
35
|
+
proof = management_proof(account, session, allow_recovery: true)
|
|
36
|
+
next failure(:elevation_required) unless proof.success? && @binding.valid?(browser_secret)
|
|
37
|
+
@store.update(account, webauthn_id: @binding.generate) unless account.webauthn_id
|
|
38
|
+
options = @rp.options_for_registration(user: {id: account.webauthn_id, name: account.email_address},
|
|
39
|
+
exclude: @store.credentials(user: account).map(&:external_id), attestation: "none",
|
|
40
|
+
authenticator_selection: {residentKey: "required", requireResidentKey: true, userVerification: "required"},
|
|
41
|
+
extensions: {credProps: true})
|
|
42
|
+
create_options(options, kind: "registration", user: account, session: session,
|
|
43
|
+
purpose: proof.credential.purpose, browser_secret: browser_secret)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def authentication_options(browser_secret:, user: nil, session: nil, purpose: nil)
|
|
48
|
+
return failure unless @binding.valid?(browser_secret)
|
|
49
|
+
if user || session || purpose
|
|
50
|
+
@store.with_user(id: user&.id) do |account|
|
|
51
|
+
next failure(:elevation_required) unless account && @sessions.current_in_transaction(user: account, session: session) &&
|
|
52
|
+
@policy.reauthentication_rule_for(purpose) && @policy.methods_for(user: account, purpose: purpose).include?(:passkey)
|
|
53
|
+
options = @rp.options_for_authentication(allow: @store.credentials(user: account).map(&:external_id), user_verification: "required")
|
|
54
|
+
create_options(options, kind: "assertion", user: account, session: session, purpose: purpose, browser_secret: browser_secret)
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
return failure(:rate_limited) unless @limiter.call(key: @digest.digest("passkey:anonymous-ceremonies"), limit: @anonymous_limit)
|
|
58
|
+
options = @rp.options_for_authentication(user_verification: "required")
|
|
59
|
+
create_options(options, kind: "assertion", browser_secret: browser_secret)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def register(transaction:, credential_response:, user:, session:, browser_secret:, nickname: nil)
|
|
64
|
+
payload = parse_payload(credential_response)
|
|
65
|
+
return failure unless payload && @binding.valid?(transaction)
|
|
66
|
+
@store.with_ceremony(digest: @digest.digest(transaction), user_id: user&.id) do |account, ceremony|
|
|
67
|
+
next failure unless valid_ceremony?(account, ceremony, "registration", browser_secret) &&
|
|
68
|
+
bound_session?(ceremony, session) && ceremony.user_id == account.id
|
|
69
|
+
proof = management_proof(account, session, purpose: ceremony.authentication_purpose)
|
|
70
|
+
next failure(:elevation_required) unless proof.success?
|
|
71
|
+
verified = verify { @rp.verify_registration(payload, ceremony.challenge, user_verification: true, user_presence: true) }
|
|
72
|
+
next verified if verified.is_a?(Result)
|
|
73
|
+
next failure unless valid_backup?(verified) && verified.raw_id == verified.response.authenticator_data.credential.id
|
|
74
|
+
label = normalized_label(nickname || "Passkey")
|
|
75
|
+
next failure unless label
|
|
76
|
+
credential = @store.create_credential(user: account, external_id: verified.id,
|
|
77
|
+
public_key: verified.public_key, sign_count: verified.sign_count,
|
|
78
|
+
backup_eligible: verified.backup_eligible?, backup_state: verified.backed_up?, nickname: label,
|
|
79
|
+
aaguid: verified.response.authenticator_data.aaguid, transports: transports(payload))
|
|
80
|
+
next failure unless credential
|
|
81
|
+
@store.update(ceremony, consumed_at: @clock.now)
|
|
82
|
+
grant = nil
|
|
83
|
+
if proof.credential.purpose == :recover_passkeys
|
|
84
|
+
@store.revoke_other_sessions(user: account, except: session, at: @clock.now)
|
|
85
|
+
@store.invalidate_proofs(user: account, at: @clock.now)
|
|
86
|
+
grant = @sessions.rotate_in_transaction(user: account, session: session, grant: proof.credential)
|
|
87
|
+
raise AddAuth::Error, "recovery replacement could not finalize" unless grant
|
|
88
|
+
@store.update(grant.session, elevated_at: nil, elevation_version: nil, elevation_expires_at: nil)
|
|
89
|
+
@notify.call(user: account, kind: :recovery_completed, at: @clock.now)
|
|
90
|
+
else
|
|
91
|
+
@notify.call(user: account, kind: :passkey_added, at: @clock.now)
|
|
92
|
+
end
|
|
93
|
+
Result.success(user: account, strategy: :passkey, credential: credential, session: grant&.session, grant: grant)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def authenticate(transaction:, credential_response:, browser_secret:, session: nil, replacing: nil, **hints)
|
|
98
|
+
payload = parse_payload(credential_response)
|
|
99
|
+
return failure unless payload && @binding.valid?(transaction)
|
|
100
|
+
locator = @store.credential(id: payload["id"])
|
|
101
|
+
return failure unless locator
|
|
102
|
+
@store.with_ceremony(digest: @digest.digest(transaction), user_id: locator.user_id, replacing: replacing) do |account, ceremony|
|
|
103
|
+
next failure unless valid_ceremony?(account, ceremony, "assertion", browser_secret)
|
|
104
|
+
next failure unless ceremony.user_id.nil? == session.nil?
|
|
105
|
+
credential = @store.credential(id: payload["id"])
|
|
106
|
+
next failure unless credential && credential.user_id == account.id && !credential.revoked_at
|
|
107
|
+
if ceremony.user_id
|
|
108
|
+
next failure(:elevation_required) unless ceremony.user_id == account.id && bound_session?(ceremony, session) &&
|
|
109
|
+
@sessions.current_in_transaction(user: account, session: session)
|
|
110
|
+
else
|
|
111
|
+
next failure unless @access.sign_in_allowed?(account, :passkey)
|
|
112
|
+
end
|
|
113
|
+
verified = verify do
|
|
114
|
+
@rp.verify_authentication(payload, ceremony.challenge, public_key: credential.public_key,
|
|
115
|
+
sign_count: credential.sign_count, user_verification: true, user_presence: true)
|
|
116
|
+
end
|
|
117
|
+
next verified if verified.is_a?(Result)
|
|
118
|
+
next failure unless verified.user_handle == account.webauthn_id && valid_backup?(verified) &&
|
|
119
|
+
verified.backup_eligible? == credential.backup_eligible
|
|
120
|
+
evidence = StepUp::Evidence.new(user_id: account.id, session_id: session&.id, method: :passkey,
|
|
121
|
+
verified_at: @clock.now, session_digest: session&.token_digest, credential_id: credential.external_id,
|
|
122
|
+
credential_version: credential.public_key, user_verification: true)
|
|
123
|
+
grant = if ceremony.user_id
|
|
124
|
+
authorization = @policy.authorize(user: account, session_id: session.id,
|
|
125
|
+
purpose: ceremony.authentication_purpose, evidence: evidence)
|
|
126
|
+
next authorization unless authorization.success?
|
|
127
|
+
@sessions.rotate_in_transaction(user: account, session: session, grant: authorization.credential)
|
|
128
|
+
else
|
|
129
|
+
@sessions.create_in_transaction(user: account, method: :passkey, proof: evidence, replacing: replacing, **hints)
|
|
130
|
+
end
|
|
131
|
+
next failure(:elevation_required) unless grant
|
|
132
|
+
@store.update(credential, sign_count: verified.sign_count, backup_state: verified.backed_up?, last_used_at: @clock.now)
|
|
133
|
+
@store.update(ceremony, consumed_at: @clock.now)
|
|
134
|
+
Result.success(user: account, strategy: :passkey, credential: grant, session: grant.session)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def list(user:, session:)
|
|
139
|
+
@store.with_user(id: user&.id) do |account|
|
|
140
|
+
next [] unless account && @sessions.current_in_transaction(user: account, session: session)
|
|
141
|
+
@store.credentials(user: account).map do |row|
|
|
142
|
+
Entry.new(id: row.id, nickname: row.nickname, created_at: row.created_at, last_used_at: row.last_used_at,
|
|
143
|
+
backup_eligible: row.backup_eligible, backup_state: row.backup_state)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def rename(user:, session:, id:, nickname:)
|
|
149
|
+
label = normalized_label(nickname)
|
|
150
|
+
return failure unless label
|
|
151
|
+
@store.with_user(id: user&.id) do |account|
|
|
152
|
+
next failure unless account && @sessions.current_in_transaction(user: account, session: session)
|
|
153
|
+
row = @store.credentials(user: account).find { |item| item.id.to_s == id.to_s }
|
|
154
|
+
next failure unless row
|
|
155
|
+
@store.update(row, nickname: label)
|
|
156
|
+
Result.success(user: account, strategy: :passkey)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def remove(user:, session:, id:)
|
|
161
|
+
@store.with_user(id: user&.id) do |account|
|
|
162
|
+
next failure(:elevation_required) unless management_proof(account, session).success?
|
|
163
|
+
rows = @store.credentials(user: account)
|
|
164
|
+
row = rows.find { |item| item.id.to_s == id.to_s }
|
|
165
|
+
next failure unless row
|
|
166
|
+
next failure unless rows.length > 1 || @access.fallback?(account)
|
|
167
|
+
@store.update(row, revoked_at: @clock.now)
|
|
168
|
+
@notify.call(user: account, kind: :passkey_removed, at: @clock.now)
|
|
169
|
+
Result.success(user: account, strategy: :passkey)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def change_policy(user:, session:, strict:, acknowledged:)
|
|
174
|
+
return failure unless [true, false].include?(strict)
|
|
175
|
+
return failure if strict && (acknowledged != true || @support_url.to_s.empty?)
|
|
176
|
+
@store.with_user(id: user&.id) do |account|
|
|
177
|
+
proof = @sessions.elevation_in_transaction(user: account, session: session, purpose: :manage_policy, policy: @policy)
|
|
178
|
+
next failure(:elevation_required) unless proof.success? && proof.credential.method == :passkey && proof.credential.user_verification
|
|
179
|
+
next failure unless @store.credentials(user: account).any?
|
|
180
|
+
row = @sessions.current_in_transaction(user: account, session: session)
|
|
181
|
+
@store.update(account, add_auth_strict: strict, add_auth_policy_version: @access.version(account) + 1)
|
|
182
|
+
@store.revoke_other_sessions(user: account, except: row, at: @clock.now)
|
|
183
|
+
@store.invalidate_proofs(user: account, at: @clock.now)
|
|
184
|
+
@store.update(row, authentication_policy_version: @access.version(account), authenticated_with: "passkey",
|
|
185
|
+
authenticated_at: proof.credential.verified_at, authentication_credential_id: proof.credential.credential_id,
|
|
186
|
+
authentication_uv: true)
|
|
187
|
+
grant = @sessions.rotate_in_transaction(user: account, session: row, grant: proof.credential)
|
|
188
|
+
raise AddAuth::Error, "policy transition could not finalize" unless grant
|
|
189
|
+
@notify.call(user: account, kind: :policy_changed, at: @clock.now)
|
|
190
|
+
Result.success(user: account, strategy: :passkey, session: grant.session, credential: grant)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def cancel(transaction:, browser_secret:)
|
|
195
|
+
return false unless @binding.valid?(transaction)
|
|
196
|
+
@store.cancel(digest: @digest.digest(transaction)) do |record|
|
|
197
|
+
next false unless record && @binding.matches?(record.browser_digest, browser_secret)
|
|
198
|
+
@store.update(record, consumed_at: @clock.now) unless record.consumed_at
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def management_proof(account, session, purpose: PURPOSE, allow_recovery: false)
|
|
206
|
+
return failure(:elevation_required) unless account
|
|
207
|
+
return failure(:elevation_required) if purpose.to_sym == :recover_passkeys && !@access.recoverable?(account)
|
|
208
|
+
proof = @sessions.elevation_in_transaction(user: account, session: session, purpose: purpose, policy: @policy)
|
|
209
|
+
return proof if proof.success? || !allow_recovery || purpose.to_sym != PURPOSE || !@access.recoverable?(account)
|
|
210
|
+
@sessions.elevation_in_transaction(user: account, session: session, purpose: :recover_passkeys, policy: @policy)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def create_options(options, kind:, browser_secret:, user: nil, session: nil, purpose: nil)
|
|
214
|
+
identifier = @binding.generate
|
|
215
|
+
@store.create_ceremony(digest: @digest.digest(identifier), challenge: options.challenge, kind: kind,
|
|
216
|
+
browser_digest: @binding.digest(browser_secret), configuration_digest: @configuration_digest,
|
|
217
|
+
user_id: user&.id, session_id: session&.id, session_digest: session&.token_digest,
|
|
218
|
+
authentication_purpose: purpose&.to_s, expires_at: @clock.now + 300)
|
|
219
|
+
Result.success(user: user, strategy: :passkey, credential: {transaction: identifier, publicKey: options.as_json})
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def valid_ceremony?(account, row, kind, browser_secret)
|
|
223
|
+
account && @eligible.call(account) == true && row && row.kind == kind && !row.consumed_at &&
|
|
224
|
+
row.expires_at > @clock.now && row.configuration_digest == @configuration_digest &&
|
|
225
|
+
@binding.matches?(row.browser_digest, browser_secret)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def bound_session?(ceremony, session)
|
|
229
|
+
session && ceremony.session_id == session.id && ceremony.session_digest == session.token_digest
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def valid_backup?(credential) = !credential.backed_up? || credential.backup_eligible?
|
|
233
|
+
|
|
234
|
+
def normalized_label(label)
|
|
235
|
+
label.strip if label.is_a?(String) && label.valid_encoding? && label.bytesize <= 240 &&
|
|
236
|
+
label.strip.length.between?(1, 60) && !label.match?(/[\x00-\x1f\x7f]/)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def transports(payload)
|
|
240
|
+
value = payload.dig("response", "transports")
|
|
241
|
+
value.is_a?(Array) ? value & %w[usb nfc ble internal hybrid] : []
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_payload(value)
|
|
245
|
+
return unless value.is_a?(Hash) && JSON.generate(value).bytesize <= PAYLOAD_LIMIT
|
|
246
|
+
id = value["id"]
|
|
247
|
+
return unless id.is_a?(String) && id.bytesize.between?(1, 2048) && /\A[A-Za-z0-9_-]+\z/.match?(id) && value["rawId"] == id
|
|
248
|
+
client = JSON.parse(Base64.urlsafe_decode64(value.fetch("response").fetch("clientDataJSON")))
|
|
249
|
+
return unless client.is_a?(Hash) && [nil, false].include?(client["crossOrigin"]) && !client.key?("topOrigin")
|
|
250
|
+
value
|
|
251
|
+
rescue ArgumentError, TypeError, KeyError, NoMethodError, JSON::ParserError
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def verify
|
|
256
|
+
yield
|
|
257
|
+
rescue WebAuthn::SignCountVerificationError
|
|
258
|
+
failure(:counter_regression)
|
|
259
|
+
rescue
|
|
260
|
+
# Library parsing/verification handles attacker-controlled binary data.
|
|
261
|
+
# This rescue surrounds verification only, never persistence or finalization.
|
|
262
|
+
failure
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_origins!(rp_id, origins, allow_localhost)
|
|
266
|
+
valid_id = rp_id.is_a?(String) && /\A[a-z0-9]+(?:[.-][a-z0-9]+)*\z/.match?(rp_id) &&
|
|
267
|
+
(PublicSuffix.domain(rp_id) || (allow_localhost && rp_id == "localhost"))
|
|
268
|
+
valid = valid_id && origins.is_a?(Array) && origins.any? && origins.all? do |origin|
|
|
269
|
+
uri = URI.parse(origin)
|
|
270
|
+
secure = uri.scheme == "https" || (allow_localhost && uri.scheme == "http" && %w[localhost 127.0.0.1].include?(uri.host))
|
|
271
|
+
secure && uri.host && (uri.host == rp_id || uri.host.end_with?(".#{rp_id}")) &&
|
|
272
|
+
!uri.userinfo && !uri.query && !uri.fragment && uri.path.empty? && uri.to_s == origin
|
|
273
|
+
end
|
|
274
|
+
raise AddAuth::Error, "configure a stable RP ID and exact HTTPS origins" unless valid
|
|
275
|
+
rescue URI::InvalidURIError, TypeError
|
|
276
|
+
raise AddAuth::Error, "configure a stable RP ID and exact HTTPS origins"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def failure(reason = :invalid_credentials)
|
|
280
|
+
@on_failure.call(reason)
|
|
281
|
+
Result.failure(reason: reason)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/runtime"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
# Included AFTER the host Authentication concern. No parallel session model.
|
|
8
|
+
module Authentication
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Authentication management always requires a named session, even if
|
|
12
|
+
# the host also serves public pages through a permissive general guard.
|
|
13
|
+
def require_add_auth_authentication
|
|
14
|
+
request_authentication unless resume_session
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def find_session_by_cookie
|
|
18
|
+
grant = Runtime.sessions.resume(signed_value: cookies.signed[:session_id])
|
|
19
|
+
add_auth_write_cookie(grant) if grant&.bearer
|
|
20
|
+
grant&.session
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start_new_session_for(user)
|
|
24
|
+
grant = Runtime.sessions.start(user: user, method: :password, replacing: add_auth_replacement_session, **add_auth_session_hints)
|
|
25
|
+
raise AddAuth::Error, "account is not eligible to sign in" unless grant
|
|
26
|
+
add_auth_accept(grant)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_auth_session_hints
|
|
30
|
+
{user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_auth_replacement_session
|
|
34
|
+
Runtime.sessions.replacement_for(signed_value: cookies.signed[:session_id])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def add_auth_accept(grant)
|
|
38
|
+
destination = Core::Sessions.safe_return(session[:return_to_after_authenticating])
|
|
39
|
+
reset_session
|
|
40
|
+
session[:return_to_after_authenticating] = destination if destination
|
|
41
|
+
add_auth_write_cookie(grant)
|
|
42
|
+
Current.session = grant.session
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_auth_write_cookie(grant)
|
|
46
|
+
cookies.signed[:session_id] = {value: grant.bearer, expires: grant.session.expires_at,
|
|
47
|
+
httponly: true, secure: request.ssl? || ::Rails.env.production?, same_site: :lax, path: "/"}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def terminate_session
|
|
51
|
+
Runtime.sessions.revoke(session: Current.session) if Current.session
|
|
52
|
+
Current.reset
|
|
53
|
+
reset_session
|
|
54
|
+
cookies.delete(:session_id, path: "/")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def request_authentication
|
|
58
|
+
session[:return_to_after_authenticating] = Core::Sessions.safe_return(request.fullpath) if request.get?
|
|
59
|
+
redirect_to Runtime.sign_in_path, status: request.get? ? :found : :see_other
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def after_authentication_url
|
|
63
|
+
Core::Sessions.safe_return(session.delete(:return_to_after_authenticating)) || "/"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module AuthenticationPages
|
|
6
|
+
def add_auth_password_sign_in
|
|
7
|
+
return head :not_found unless Runtime.config.passwords_enabled
|
|
8
|
+
intake = Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
|
|
9
|
+
action: :sign_in, challenge_token: challenge_token)
|
|
10
|
+
return intake_failure(intake) if intake.is_a?(Symbol)
|
|
11
|
+
grant = Runtime.sessions.authenticate(identifier: intake, password: params[:password], replacing: add_auth_replacement_session, **add_auth_session_hints) do
|
|
12
|
+
Runtime.authenticate_password(identifier: intake, password: params[:password])
|
|
13
|
+
end
|
|
14
|
+
if grant
|
|
15
|
+
destination = after_authentication_url
|
|
16
|
+
add_auth_accept(grant)
|
|
17
|
+
redirect_to destination, status: :see_other
|
|
18
|
+
else
|
|
19
|
+
@error = "Email or password is incorrect. Try again or request a sign-in link."
|
|
20
|
+
page("form", status: :unprocessable_entity)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def valid_request_origin?
|
|
27
|
+
Core::Intake.origin_allowed?(origin: request.origin, base_url: request.base_url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def challenge_token
|
|
31
|
+
params[:challenge_token].presence || params["cf-turnstile-response"].presence || params["g-recaptcha-response"].presence
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def service_unavailable
|
|
35
|
+
@error = "Sign-in is temporarily unavailable. Please try again shortly."
|
|
36
|
+
response.set_header("Retry-After", "60")
|
|
37
|
+
page("form", status: :service_unavailable)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def private_response
|
|
41
|
+
response.set_header("Cache-Control", "no-store")
|
|
42
|
+
response.set_header("Referrer-Policy", "no-referrer")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def page(partial, status: :ok)
|
|
46
|
+
@page_partial = authentication_partial(partial)
|
|
47
|
+
respond_to do |format|
|
|
48
|
+
format.html { render "add_auth/sign_ins/show", layout: "add_auth/authentication", status: status }
|
|
49
|
+
format.turbo_stream { render turbo_stream: turbo_stream.update("add_auth-content", partial: @page_partial), status: status }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authentication_partial(partial) = "add_auth/sign_ins/#{partial}"
|
|
54
|
+
|
|
55
|
+
def intake_failure(reason)
|
|
56
|
+
@error, status = {
|
|
57
|
+
rate_limited: ["Too many attempts. Try again in five minutes.", :too_many_requests],
|
|
58
|
+
challenge_unavailable: ["Verification is temporarily unavailable. Please try again shortly.", :service_unavailable],
|
|
59
|
+
challenge_rejected: ["Verification failed. Please try again.", :unprocessable_entity]
|
|
60
|
+
}.fetch(reason, ["Check your credentials and try again.", :unprocessable_entity])
|
|
61
|
+
response.set_header("Retry-After", "300") if %i[too_many_requests service_unavailable].include?(status)
|
|
62
|
+
page("form", status: status)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/message_encryptor"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
# A pending delivery contains a bearer: encrypt it with a separate key and
|
|
8
|
+
# bind it to its token digest. Never enqueue or log the plaintext argument.
|
|
9
|
+
class DeliveryCipher
|
|
10
|
+
def initialize(key:)
|
|
11
|
+
raise ArgumentError, "delivery key must be 32 bytes" unless key.is_a?(String) && key.bytesize == 32
|
|
12
|
+
@encryptor = ActiveSupport::MessageEncryptor.new(key.dup.freeze,
|
|
13
|
+
cipher: "aes-256-gcm", serializer: :json)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def encrypt(token:, digest:, expires_at:)
|
|
17
|
+
@encryptor.encrypt_and_sign(token, purpose: purpose(digest), expires_at: expires_at)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def decrypt(payload:, digest:)
|
|
21
|
+
@encryptor.decrypt_and_verify(payload, purpose: purpose(digest))
|
|
22
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect = "#<#{self.class} [FILTERED]>"
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def purpose(digest) = "add_auth.email-delivery.v1:#{digest}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|