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,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
module DeliveryState
|
|
7
|
+
def revoke(record:, at:)
|
|
8
|
+
record.update!(revoked_at: at, delivery_payload: nil)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def lease(record:, key:, until_time:)
|
|
12
|
+
record.update!(delivery_lease_key: key, delivery_lease_until: until_time)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def delivered(record:, at:)
|
|
16
|
+
record.update!(delivered_at: at, delivery_payload: nil, delivery_lease_key: nil, delivery_lease_until: nil)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
require "add_auth/rails/stores/delivery_state"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Rails
|
|
8
|
+
module Stores
|
|
9
|
+
# Lock the account before reading token state, serializing issuance,
|
|
10
|
+
# consumption and revocation. All injected models must share a pool.
|
|
11
|
+
# Host finalizers may perform DB writes only, on this same connection.
|
|
12
|
+
class EmailTokens
|
|
13
|
+
include DeliveryState
|
|
14
|
+
|
|
15
|
+
def initialize(user_model:, token_model:, session_model:, identifier: :email_address)
|
|
16
|
+
@users, @tokens, @sessions = user_model, token_model, session_model
|
|
17
|
+
unless [@tokens, @sessions].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
|
|
18
|
+
raise ArgumentError, "authentication models must share one connection pool"
|
|
19
|
+
end
|
|
20
|
+
unless @users.column_names.include?(identifier.to_s)
|
|
21
|
+
raise ArgumentError, "unknown identifier column"
|
|
22
|
+
end
|
|
23
|
+
@identifier = identifier
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def with_user(identifier:)
|
|
27
|
+
with_account(@users.find_by(@identifier => identifier)) { |user| yield user }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_token(digest:, current_session: nil)
|
|
31
|
+
raise ArgumentError, "digest must be a nonempty string" unless digest.is_a?(String) && !digest.empty?
|
|
32
|
+
|
|
33
|
+
# A request may have inspected this token before another connection
|
|
34
|
+
# consumed it. Never authorize from Active Record's query cache.
|
|
35
|
+
@tokens.uncached do
|
|
36
|
+
locator = @tokens.find_by(digest: digest)
|
|
37
|
+
with_account(locator && @users.find_by(@users.primary_key => locator.user_id), additional_id: current_session&.user_id) do |user|
|
|
38
|
+
yield user, user && @tokens.find_by(digest: digest, user_id: user.id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def replace_pending(user:, purpose: "sign_in", **attributes)
|
|
44
|
+
scope = attributes.slice(:session_id, :authentication_purpose)
|
|
45
|
+
@tokens.where(user_id: user.id, purpose: purpose, consumed_at: nil, revoked_at: nil, **scope)
|
|
46
|
+
.update_all(revoked_at: attributes.fetch(:created_at), delivery_payload: nil)
|
|
47
|
+
@tokens.create!(**attributes, user_id: user.id, purpose: purpose)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def session_for(user:, id:)
|
|
51
|
+
@sessions.uncached { @sessions.find_by(id: id, user_id: user.id) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def consume(record:, at:)
|
|
55
|
+
record.update!(consumed_at: at, delivery_payload: nil)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# This capability owns creation; a model's previously_new_record? flag
|
|
59
|
+
# cannot establish which transaction created it. The writer expires when
|
|
60
|
+
# the callback exits and may create exactly one session for this account.
|
|
61
|
+
def finalize_session(user:)
|
|
62
|
+
transaction = @users.current_transaction
|
|
63
|
+
created = nil
|
|
64
|
+
active = true
|
|
65
|
+
writer = lambda do |**attributes|
|
|
66
|
+
unless active && transaction.open? && @users.current_transaction.equal?(transaction) && !created
|
|
67
|
+
raise AddAuth::Error, "session writer is no longer available"
|
|
68
|
+
end
|
|
69
|
+
created = @sessions.create!(**attributes.except(:user_id), user_id: user.id)
|
|
70
|
+
end
|
|
71
|
+
result = yield writer
|
|
72
|
+
unless created && result.equal?(created) && created.persisted? && created.user_id == user.id
|
|
73
|
+
raise AddAuth::Error, "finalizer must persist a session using the transaction writer"
|
|
74
|
+
end
|
|
75
|
+
result
|
|
76
|
+
ensure
|
|
77
|
+
active = false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def refresh_session(session) = session.reload
|
|
81
|
+
|
|
82
|
+
def issued?(request_id:) = @tokens.exists?(request_id: request_id)
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def with_account(user, additional_id: nil)
|
|
87
|
+
AccountLock.new(@users).call(id: user&.id, additional_id: additional_id) { |account| yield account }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module Stores
|
|
6
|
+
class Maintenance
|
|
7
|
+
def initialize(model:, kind:)
|
|
8
|
+
@model, @kind = model, kind
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def purge_expired(before:, now:, limit:)
|
|
12
|
+
scope = @model.where("expires_at <= ?", before)
|
|
13
|
+
if @kind == :session
|
|
14
|
+
scope = scope.or(@model.where("revoked_at <= ?", before))
|
|
15
|
+
elsif @kind != :ceremony
|
|
16
|
+
scope = without_live_lease(scope, now)
|
|
17
|
+
end
|
|
18
|
+
bounded(scope, limit).delete_all
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def erase_expired_payloads(now:, limit:)
|
|
22
|
+
scope = without_live_lease(@model.where("expires_at <= ?", now).where.not(delivery_payload: nil), now)
|
|
23
|
+
bounded(scope, limit).update_all(delivery_payload: nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pending_ids(now:, limit:)
|
|
27
|
+
scope = @model.where(revoked_at: nil, delivered_at: nil).where.not(delivery_payload: nil)
|
|
28
|
+
.where("expires_at > ?", now)
|
|
29
|
+
without_live_lease(scope, now).order(:id).limit(limit).pluck(:id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def without_live_lease(scope, now)
|
|
35
|
+
scope.where("delivery_lease_until IS NULL OR delivery_lease_until <= ?", now)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def bounded(scope, limit)
|
|
39
|
+
# Repeat the predicate after selection; a concurrent refresh/lease
|
|
40
|
+
# acquisition protects its row even if it appeared in this ID batch.
|
|
41
|
+
scope.where(id: scope.order(:id).limit(limit).pluck(:id))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Stores
|
|
8
|
+
class Passkeys
|
|
9
|
+
def initialize(user_model:, session_model:, credential_model:, ceremony_model:, token_model:)
|
|
10
|
+
@users, @sessions, @credentials, @ceremonies = user_model, session_model, credential_model, ceremony_model
|
|
11
|
+
@tokens = token_model
|
|
12
|
+
unless [@sessions, @credentials, @ceremonies, @tokens].all? { |model| model.connection_pool.equal?(@users.connection_pool) }
|
|
13
|
+
raise ArgumentError, "authentication models must share one connection pool"
|
|
14
|
+
end
|
|
15
|
+
@lock = AccountLock.new(@users)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with_user(id:, &block) = @lock.call(id: id, &block)
|
|
19
|
+
def credentials(user:) = @credentials.where(user_id: user.id, revoked_at: nil).order(:created_at).to_a
|
|
20
|
+
def credential(id:) = @credentials.find_by(external_id: id)
|
|
21
|
+
def update(record, **attributes) = record.update!(**attributes)
|
|
22
|
+
def create_ceremony(**attributes) = @ceremonies.create!(**attributes)
|
|
23
|
+
|
|
24
|
+
def with_ceremony(digest:, user_id:, replacing: nil)
|
|
25
|
+
@lock.call(id: user_id, additional_id: replacing&.user_id) do |user|
|
|
26
|
+
# Anonymous assertions for different accounts can share a challenge;
|
|
27
|
+
# locking only those different accounts would not prevent replay.
|
|
28
|
+
record = @ceremonies.lock.find_by(digest: digest) if user
|
|
29
|
+
yield user, record
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_credential(user:, **attributes)
|
|
34
|
+
@credentials.transaction(requires_new: true) do
|
|
35
|
+
@credentials.create!(**attributes, user_id: user.id)
|
|
36
|
+
end
|
|
37
|
+
rescue ActiveRecord::RecordNotUnique
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cancel(digest:)
|
|
42
|
+
@ceremonies.transaction do
|
|
43
|
+
if @ceremonies.connection.adapter_name == "SQLite"
|
|
44
|
+
pk = @ceremonies.connection.quote_column_name(@ceremonies.primary_key)
|
|
45
|
+
@ceremonies.where(digest: digest).update_all("#{pk} = #{pk}")
|
|
46
|
+
end
|
|
47
|
+
@ceremonies.uncached { yield @ceremonies.lock.find_by(digest: digest) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def revoke_other_sessions(user:, except:, at:)
|
|
52
|
+
@sessions.where(user_id: user.id, revoked_at: nil).where.not(id: except.id).update_all(revoked_at: at)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def invalidate_proofs(user:, at:)
|
|
56
|
+
@tokens.where(user_id: user.id, consumed_at: nil, revoked_at: nil).update_all(revoked_at: at, delivery_payload: nil)
|
|
57
|
+
@ceremonies.where(user_id: user.id, consumed_at: nil).update_all(consumed_at: at)
|
|
58
|
+
@sessions.where(user_id: user.id).update_all(elevated_at: nil, elevation_version: nil, elevation_expires_at: nil)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/delivery_state"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Stores
|
|
8
|
+
class SecurityEvents
|
|
9
|
+
include DeliveryState
|
|
10
|
+
|
|
11
|
+
def initialize(user_model:, event_model:)
|
|
12
|
+
@users, @events = user_model, event_model
|
|
13
|
+
raise ArgumentError, "authentication models must share one connection pool" unless @users.connection_pool.equal?(@events.connection_pool)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def append(user:, **attributes)
|
|
17
|
+
raise AddAuth::Error, "security notifications must join the account transaction" unless @users.current_transaction.open?
|
|
18
|
+
event = @events.create!(**attributes, user_id: user.id)
|
|
19
|
+
@users.current_transaction.after_commit do
|
|
20
|
+
job = ::AddAuth::SecurityNotificationJob.perform_later(event.id)
|
|
21
|
+
raise AddAuth::Error unless job
|
|
22
|
+
rescue
|
|
23
|
+
ActiveSupport::Notifications.instrument("notification_enqueue_failed.add_auth", event_id: event.id)
|
|
24
|
+
end
|
|
25
|
+
event
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_token(digest:)
|
|
29
|
+
@events.transaction do
|
|
30
|
+
if @events.connection.adapter_name == "SQLite"
|
|
31
|
+
pk = @events.connection.quote_column_name(@events.primary_key)
|
|
32
|
+
@events.where(digest: digest).update_all("#{pk} = #{pk}")
|
|
33
|
+
end
|
|
34
|
+
@events.uncached { yield nil, @events.lock.find_by(digest: digest) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/stores/account_lock"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Rails
|
|
7
|
+
module Stores
|
|
8
|
+
class Sessions
|
|
9
|
+
def initialize(user_model:, session_model:)
|
|
10
|
+
@users, @sessions = user_model, session_model
|
|
11
|
+
raise ArgumentError, "authentication models must share one connection pool" unless @users.connection_pool.equal?(@sessions.connection_pool)
|
|
12
|
+
@lock = AccountLock.new(@users)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def with_user(id:, replacing: nil, &block) = @lock.call(id: id, additional_id: replacing&.user_id, &block)
|
|
16
|
+
|
|
17
|
+
def with_identifier(identifier:, replacing: nil, &block)
|
|
18
|
+
with_user(id: @users.find_by(email_address: identifier)&.id, replacing: replacing, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with_session(digest: nil, id: nil)
|
|
22
|
+
@sessions.uncached do
|
|
23
|
+
criteria = digest ? {token_digest: digest} : {id: id}
|
|
24
|
+
locator = @sessions.find_by(criteria)
|
|
25
|
+
@lock.call(id: locator&.user_id) do |user|
|
|
26
|
+
yield user, user && @sessions.find_by(**criteria, user_id: user.id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create(user:, **attributes)
|
|
32
|
+
raise AddAuth::Error, "session finalization requires an account transaction" unless @users.connection.transaction_open?
|
|
33
|
+
@sessions.create!(**attributes, user_id: user.id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update(row, **attributes) = row.update!(**attributes)
|
|
37
|
+
|
|
38
|
+
# Query cutoffs come from Core; Core still authorizes every returned row.
|
|
39
|
+
def list_for_user(user_id:, before:, excluding:, limit:, now:, active_after:, legacy:)
|
|
40
|
+
scope = @sessions.where(user_id: user_id, revoked_at: nil)
|
|
41
|
+
scope = scope.where("id < ?", before) if before
|
|
42
|
+
scope = scope.where.not(id: excluding) if excluding
|
|
43
|
+
scope = scope.where("(expires_at IS NULL OR expires_at > ?) AND (last_seen_at IS NULL OR last_seen_at > ?)", now, active_after)
|
|
44
|
+
scope = scope.where.not(expires_at: nil).where.not(last_seen_at: nil) unless legacy
|
|
45
|
+
scope.order(id: :desc).limit(limit).to_a
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_for_user(user_id:, session_id:) = @sessions.find_by(user_id: user_id, id: session_id)
|
|
49
|
+
|
|
50
|
+
def find_for_user_in_transaction(user_id:, session_id:)
|
|
51
|
+
raise AddAuth::Error, "session lookup requires an account transaction" unless @users.connection.transaction_open?
|
|
52
|
+
@sessions.uncached { @sessions.find_by(id: session_id, user_id: user_id) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def revoke_all_in_transaction(user_id:, at:)
|
|
56
|
+
raise AddAuth::Error, "revocation requires an account transaction" unless @users.connection.transaction_open?
|
|
57
|
+
@sessions.where(user_id: user_id, revoked_at: nil).update_all(revoked_at: at, updated_at: at)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Rails
|
|
5
|
+
module UserLifecycle
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
after_update :add_auth_invalidate_proofs
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def add_auth_invalidate_proofs
|
|
15
|
+
return unless Core::Intake.revoke_after_change?(saved_changes)
|
|
16
|
+
now = Time.current
|
|
17
|
+
if AddAuth.configuration.notifications.enabled
|
|
18
|
+
kind = saved_changes.key?("password_digest") ? :password_changed : :email_changed
|
|
19
|
+
Runtime.security_events.issue(user: self, kind: kind, at: now)
|
|
20
|
+
if saved_change_to_email_address? && email_address_before_last_save != email_address
|
|
21
|
+
Runtime.security_events.issue(user: self, kind: :email_changed, at: now, recipient: email_address_before_last_save)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
if defined?(::AddAuthCeremony) && ::AddAuthCeremony.table_exists?
|
|
25
|
+
::AddAuthCeremony.where(user_id: id, consumed_at: nil).update_all(consumed_at: now)
|
|
26
|
+
end
|
|
27
|
+
sessions.where(revoked_at: nil).update_all(revoked_at: now)
|
|
28
|
+
if defined?(::AddAuthSignInToken) && ::AddAuthSignInToken.table_exists?
|
|
29
|
+
::AddAuthSignInToken.where(user_id: id, consumed_at: nil, revoked_at: nil)
|
|
30
|
+
.update_all(revoked_at: now, delivery_payload: nil)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
# Every Core entry point returns one of these instead of raising for an
|
|
5
|
+
# authentication outcome (it still raises for programmer error) or
|
|
6
|
+
# returning a bare boolean. See docs/authentication-gem-plan.md section 2,
|
|
7
|
+
# "Everything returns a Result," for the rationale: a closed set of reason
|
|
8
|
+
# codes is something a host can exhaustively match/translate, and it can't
|
|
9
|
+
# accidentally leak *which* half of a credential check failed.
|
|
10
|
+
class Result
|
|
11
|
+
# Closed set. Adding a value here is a deliberate, documented decision --
|
|
12
|
+
# hosts are expected to `case`/`in` over this list.
|
|
13
|
+
REASONS = %i[
|
|
14
|
+
invalid_credentials unknown_identifier unconfirmed
|
|
15
|
+
locked disabled expired_token
|
|
16
|
+
consumed_token revoked_token challenge_rejected
|
|
17
|
+
challenge_unavailable rate_limited origin_mismatch
|
|
18
|
+
counter_regression elevation_required
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :user, :strategy, :credential, :reason, :session, :grant
|
|
22
|
+
|
|
23
|
+
def self.success(user:, strategy:, credential: nil, session: nil, grant: nil)
|
|
24
|
+
new(success: true, user:, strategy:, credential:, session:, grant:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.failure(reason:)
|
|
28
|
+
raise ArgumentError, "unknown Result reason: #{reason.inspect}" unless REASONS.include?(reason)
|
|
29
|
+
|
|
30
|
+
new(success: false, reason:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def success?
|
|
34
|
+
@success
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def failure?
|
|
38
|
+
!success?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inspect = "#<AddAuth::Result success=#{success?} strategy=#{strategy.inspect} reason=#{reason.inspect}>"
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def initialize(success:, user: nil, strategy: nil, credential: nil, reason: nil, session: nil, grant: nil)
|
|
46
|
+
@success = success
|
|
47
|
+
@user = user
|
|
48
|
+
@strategy = strategy
|
|
49
|
+
@credential = credential
|
|
50
|
+
@reason = reason
|
|
51
|
+
@session, @grant = session, grant
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Testing
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def delivered_link(mail, purpose: :sign_in)
|
|
10
|
+
path = {sign_in: "/sign-in/link", reauthentication: "/reauthenticate/link", recovery: "/recover/link"}.fetch(purpose)
|
|
11
|
+
urls = mail.body.decoded.scan(%r{https?://[^\s<>]+}).filter_map do |text|
|
|
12
|
+
uri = URI.parse(text)
|
|
13
|
+
uri if uri.path == path && URI.decode_www_form(uri.query.to_s).to_h["token"]
|
|
14
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
raise ArgumentError, "expected exactly one #{purpose} link" unless urls.length == 1
|
|
18
|
+
urls.first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with_virtual_authenticator(driver, **options)
|
|
22
|
+
require "selenium-webdriver"
|
|
23
|
+
defaults = {protocol: :ctap2, transport: :internal, resident_key: true, user_verification: true, user_verified: true}
|
|
24
|
+
authenticator = driver.add_virtual_authenticator(Selenium::WebDriver::VirtualAuthenticatorOptions.new(**defaults, **options))
|
|
25
|
+
yield authenticator
|
|
26
|
+
ensure
|
|
27
|
+
authenticator.remove! if authenticator&.valid?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/add_auth.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/version"
|
|
4
|
+
require "add_auth/result"
|
|
5
|
+
require "add_auth/configuration"
|
|
6
|
+
|
|
7
|
+
# Layer 1 (plain Ruby, no Rails dependency). See
|
|
8
|
+
# docs/authentication-gem-plan.md section 2 in the workspace repo for the
|
|
9
|
+
# two-layer architecture this module is built around: AddAuth::Core holds
|
|
10
|
+
# every security decision and knows nothing about controllers, views, or the
|
|
11
|
+
# session hash. AddAuth::Rails (loaded below, only inside a Rails app) is the
|
|
12
|
+
# thin engine that wires Core into a host application and generates
|
|
13
|
+
# ejectable, disposable UI on top of it.
|
|
14
|
+
require "add_auth/core/browser_binding"
|
|
15
|
+
require "add_auth/core/access_policy"
|
|
16
|
+
require "add_auth/core/sessions"
|
|
17
|
+
require "add_auth/core/step_up"
|
|
18
|
+
require "add_auth/core/intake"
|
|
19
|
+
require "add_auth/core/rate_limit"
|
|
20
|
+
require "add_auth/core/delivery"
|
|
21
|
+
require "add_auth/core/maintenance"
|
|
22
|
+
require "add_auth/core/security_events"
|
|
23
|
+
require "add_auth/core/strategies/email_link"
|
|
24
|
+
require "add_auth/core/strategies/passkey"
|
|
25
|
+
require "add_auth/core/challenge/base"
|
|
26
|
+
require "add_auth/core/challenge/http"
|
|
27
|
+
require "add_auth/core/challenge/null"
|
|
28
|
+
require "add_auth/core/challenge/test"
|
|
29
|
+
require "add_auth/core/challenge/turnstile"
|
|
30
|
+
require "add_auth/core/challenge/recaptcha"
|
|
31
|
+
require "add_auth/core/digest/base"
|
|
32
|
+
require "add_auth/core/digest/hmac"
|
|
33
|
+
|
|
34
|
+
module AddAuth
|
|
35
|
+
class Error < StandardError; end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def configuration
|
|
39
|
+
@configuration ||= Configuration.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def configure
|
|
43
|
+
yield configuration
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if defined?(::Rails::Engine)
|
|
49
|
+
require "add_auth/rails/engine"
|
|
50
|
+
require "add_auth/rails/delivery_cipher"
|
|
51
|
+
require "add_auth/rails/stores/email_tokens"
|
|
52
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Generators
|
|
7
|
+
# `bin/rails g add_auth:challenge turnstile` (or `recaptcha`) writes an
|
|
8
|
+
# opt-in initializer without touching the host's existing initializer.
|
|
9
|
+
class ChallengeGenerator < ::Rails::Generators::Base
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
argument :provider, type: :string, required: true, banner: "turnstile|recaptcha"
|
|
12
|
+
class_option :version, type: :string, default: "v3", desc: "reCAPTCHA version (v2 or v3)"
|
|
13
|
+
|
|
14
|
+
def validate_provider
|
|
15
|
+
return if %w[turnstile recaptcha].include?(provider.to_s.downcase)
|
|
16
|
+
|
|
17
|
+
raise Thor::Error, "Choose a challenge provider: turnstile or recaptcha."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_version
|
|
21
|
+
return if provider.to_s.downcase == "turnstile"
|
|
22
|
+
return if %w[v2 v3].include?(options[:version].to_s.downcase)
|
|
23
|
+
|
|
24
|
+
raise Thor::Error, "reCAPTCHA version must be v2 or v3."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_initializer
|
|
28
|
+
path = File.join(destination_root, "config/initializers/add_auth_challenge.rb")
|
|
29
|
+
if File.exist?(path)
|
|
30
|
+
say "Skipping #{path}; review the existing challenge configuration.", :yellow
|
|
31
|
+
return
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
empty_directory "config/initializers"
|
|
35
|
+
template "#{provider.to_s.downcase}.rb.tt", path
|
|
36
|
+
say "Challenge server verification configured for #{provider}. Review environment keys and allowed hostnames."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_route
|
|
40
|
+
path = File.join(destination_root, "config/routes.rb")
|
|
41
|
+
return unless File.file?(path)
|
|
42
|
+
return if File.read(path).include?("add_auth/challenge.js")
|
|
43
|
+
|
|
44
|
+
route 'get "add_auth/challenge.js", to: "add_auth/assets#challenge"'
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by add_auth:challenge. Set both keys before enabling this in a
|
|
4
|
+
# deployed environment. v2 uses a checkbox; v3 additionally checks action and
|
|
5
|
+
# score. The adapter always validates the token server-side.
|
|
6
|
+
site_key = ENV["RECAPTCHA_SITE_KEY"]
|
|
7
|
+
secret_key = ENV["RECAPTCHA_SECRET_KEY"]
|
|
8
|
+
|
|
9
|
+
if site_key.to_s.empty? || secret_key.to_s.empty?
|
|
10
|
+
raise AddAuth::Error, "Configured captcha requires both provider keys in production" if Rails.env.production?
|
|
11
|
+
Rails.logger.warn("AddAuth reCAPTCHA is disabled: set RECAPTCHA_SITE_KEY and RECAPTCHA_SECRET_KEY")
|
|
12
|
+
else
|
|
13
|
+
if Rails.env.production? && ENV.fetch("RECAPTCHA_ALLOWED_HOSTNAMES", "").split(",").all? { |host| host.strip.empty? }
|
|
14
|
+
raise AddAuth::Error, "Configured captcha requires allowed hostnames in production"
|
|
15
|
+
end
|
|
16
|
+
AddAuth.configure do |config|
|
|
17
|
+
config.challenge = AddAuth::Core::Challenge::Recaptcha.new(
|
|
18
|
+
site_key: site_key,
|
|
19
|
+
secret_key: secret_key,
|
|
20
|
+
version: :<%= options[:version].to_s.downcase %>,
|
|
21
|
+
minimum_score: ENV.fetch("RECAPTCHA_MINIMUM_SCORE", "0.5"),
|
|
22
|
+
expected_action: ENV["RECAPTCHA_EXPECTED_ACTION"].presence,
|
|
23
|
+
allowed_hostnames: ENV.fetch("RECAPTCHA_ALLOWED_HOSTNAMES", "").split(",")
|
|
24
|
+
)
|
|
25
|
+
config.challenge_on = %i[sign_in email_link]
|
|
26
|
+
config.challenge_when_unavailable = :closed
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by add_auth:challenge. Set both keys before enabling this in a
|
|
4
|
+
# deployed environment. The adapter always validates the token server-side.
|
|
5
|
+
site_key = ENV["TURNSTILE_SITE_KEY"]
|
|
6
|
+
secret_key = ENV["TURNSTILE_SECRET_KEY"]
|
|
7
|
+
|
|
8
|
+
if site_key.to_s.empty? || secret_key.to_s.empty?
|
|
9
|
+
raise AddAuth::Error, "Configured captcha requires both provider keys in production" if Rails.env.production?
|
|
10
|
+
Rails.logger.warn("AddAuth Turnstile is disabled: set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY")
|
|
11
|
+
else
|
|
12
|
+
if Rails.env.production? && ENV.fetch("TURNSTILE_ALLOWED_HOSTNAMES", "").split(",").all? { |host| host.strip.empty? }
|
|
13
|
+
raise AddAuth::Error, "Configured captcha requires allowed hostnames in production"
|
|
14
|
+
end
|
|
15
|
+
AddAuth.configure do |config|
|
|
16
|
+
config.challenge = AddAuth::Core::Challenge::Turnstile.new(
|
|
17
|
+
site_key: site_key,
|
|
18
|
+
secret_key: secret_key,
|
|
19
|
+
allowed_hostnames: ENV.fetch("TURNSTILE_ALLOWED_HOSTNAMES", "").split(",")
|
|
20
|
+
)
|
|
21
|
+
config.challenge_on = %i[sign_in email_link]
|
|
22
|
+
config.challenge_when_unavailable = :closed
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "generators/add_auth/ejection"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Generators
|
|
8
|
+
class ControllersGenerator < ::Rails::Generators::Base
|
|
9
|
+
include Ejection
|
|
10
|
+
|
|
11
|
+
def generate = eject(:controllers)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "add_auth/rails/ejection"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Generators
|
|
7
|
+
module Ejection
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def eject(kind, only: "all")
|
|
11
|
+
Rails::Ejection.new(host_root: destination_root).install(kind: kind, only: only).each do |result|
|
|
12
|
+
say "#{result[:preserved] ? "Preserved" : "Created"} #{result[:path]}"
|
|
13
|
+
end
|
|
14
|
+
say "Run bin/rails add_auth:doctor after gem upgrades to review upstream changes."
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|