add_auth 0.2.2 → 0.4.0
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 +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +64 -17
- data/ROADMAP.md +98 -43
- data/SECURITY.md +8 -0
- data/app/controllers/add_auth/accounts_controller.rb +146 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +17 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +295 -0
- data/lib/add_auth/core/account_policy.rb +68 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +6 -1
- data/lib/add_auth/rails/doctor.rb +80 -6
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +179 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/feature_configuration.rb +21 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
- data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- metadata +101 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
# The native verifier protects this application handoff. It does not replace
|
|
9
|
+
# upstream provider state/nonce/PKCE, which remain the library's responsibility.
|
|
10
|
+
class MobileHandoffs
|
|
11
|
+
PURPOSE = ExternalIdentities::MOBILE
|
|
12
|
+
CODE = /\Aah1:[A-Za-z0-9_-]{43}\z/
|
|
13
|
+
STATE = /\A[A-Za-z0-9_-]{43,128}\z/
|
|
14
|
+
VERIFIER = /\A[A-Za-z0-9._~-]{43,128}\z/
|
|
15
|
+
CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/
|
|
16
|
+
Handoff = Struct.new(:code, :state, :callback) do
|
|
17
|
+
def inspect = "#<AddAuth::Core::MobileHandoffs::Handoff [FILTERED]>"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(store:, profile:, sessions:, access_policy:, digest:, clock: Time, lifetime: 60)
|
|
21
|
+
raise ArgumentError, "handoff lifetime must be between 1 and 120 seconds" unless lifetime.is_a?(Integer) && lifetime.between?(1, 120)
|
|
22
|
+
@store, @profile, @sessions, @access, @digest, @clock, @lifetime = store, profile, sessions, access_policy, digest, clock, lifetime
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def begin_transaction(pending:, client_id:, callback:, state:, code_challenge:, code_challenge_method:)
|
|
26
|
+
return failure unless pending.is_a?(ExternalIdentities::Pending) && pending.purpose == PURPOSE &&
|
|
27
|
+
@profile&.client?(client_id) && @profile.callback(client_id) == callback && callback &&
|
|
28
|
+
text?(state, STATE) && text?(code_challenge, CHALLENGE) && code_challenge_method == "S256" &&
|
|
29
|
+
pending.issued_at <= @clock.now && pending.expires_at > @clock.now
|
|
30
|
+
row = @store.create_pending(external_digest: @digest.digest(pending.id), client_id: client_id, callback: callback,
|
|
31
|
+
state: state, challenge_digest: @digest.digest(code_challenge), issued_at: pending.issued_at, expires_at: pending.expires_at)
|
|
32
|
+
row ? Result.success(user: nil, strategy: :external_identity) : failure
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called only from ExternalIdentities' verified binding finalizer, inside
|
|
36
|
+
# the owning account transaction. No Session or bearer exists at this point.
|
|
37
|
+
def issue_in_transaction(user:, proof:, pending:)
|
|
38
|
+
return failure unless proof.is_a?(ExternalIdentities::SessionProof) && proof.user_id == user.id && pending.purpose == PURPOSE
|
|
39
|
+
row = @store.by_external_digest(digest: @digest.digest(pending.id))
|
|
40
|
+
return failure unless row && current_profile?(row) && !row.digest && !row.consumed_at && row.expires_at > @clock.now
|
|
41
|
+
code = "ah1:#{SecureRandom.urlsafe_base64(32)}"
|
|
42
|
+
issued = @store.issue(row: row, user_id: user.id, digest: @digest.digest(code),
|
|
43
|
+
credential_id: proof.credential_id, credential_version: proof.credential_version,
|
|
44
|
+
policy_version: @access.version(user), authenticated_at: proof.verified_at,
|
|
45
|
+
expires_at: [row.expires_at, @clock.now + @lifetime].min)
|
|
46
|
+
return failure unless issued
|
|
47
|
+
Result.success(user: user, strategy: :external_identity, credential: Handoff.new(code: code, state: row.state, callback: row.callback))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exchange(code:, state:, code_verifier:, client_id:, ip: nil, user_agent: nil)
|
|
51
|
+
return failure unless @profile&.client?(client_id) && text?(code, CODE) && text?(state, STATE) && text?(code_verifier, VERIFIER)
|
|
52
|
+
challenge = Base64.urlsafe_encode64(::Digest::SHA256.digest(code_verifier), padding: false)
|
|
53
|
+
@store.with_code(digest: @digest.digest(code)) do |user, row|
|
|
54
|
+
now = @clock.now
|
|
55
|
+
next failure unless user && row && current_profile?(row) && row.client_id == client_id &&
|
|
56
|
+
@digest.matches?(row.challenge_digest, challenge) && @digest.matches?(@digest.digest(row.state), state)
|
|
57
|
+
next failure(:consumed_token) if row.consumed_at
|
|
58
|
+
next failure(:expired_token) unless row.issued_at <= now && row.expires_at > now
|
|
59
|
+
next failure unless row.policy_version == @access.version(user)
|
|
60
|
+
proof = ExternalIdentities::SessionProof.send(:new, user_id: user.id, credential_id: row.credential_id,
|
|
61
|
+
credential_version: row.credential_version, verified_at: row.authenticated_at)
|
|
62
|
+
grant = @sessions.create_in_transaction(user: user, method: :external_identity, proof: proof,
|
|
63
|
+
transport: :mobile, client_id: row.client_id, ip_address: hint(ip, 128), user_agent: hint(user_agent, 512))
|
|
64
|
+
next failure unless grant
|
|
65
|
+
# Session creation and consume commit together. An unexpected lost CAS
|
|
66
|
+
# rolls back issuance; it must never return a credential for this request.
|
|
67
|
+
raise AddAuth::Error, "mobile handoff could not finalize" unless @store.consume(row: row, at: now)
|
|
68
|
+
Result.success(user: user, strategy: :external_identity, session: grant.session, grant: grant)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def current_profile?(row) = @profile&.client?(row.client_id) && @profile.callback(row.client_id) == row.callback
|
|
75
|
+
def text?(value, pattern) = value.is_a?(String) && value.ascii_only? && pattern.match?(value)
|
|
76
|
+
def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
|
|
77
|
+
|
|
78
|
+
def hint(value, limit)
|
|
79
|
+
value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
# Host-selected finite API sessions. Client identifiers select configuration,
|
|
8
|
+
# never prove application identity or authorize an account.
|
|
9
|
+
class MobileProfile
|
|
10
|
+
PATTERN = /\Aam1:[A-Za-z0-9_-]{43}\z/
|
|
11
|
+
attr_reader :lifetime, :idle_timeout, :clients
|
|
12
|
+
|
|
13
|
+
def initialize(lifetime:, idle_timeout:, clients:, callbacks: {})
|
|
14
|
+
unless [lifetime, idle_timeout].all? { |value| value.is_a?(Integer) && value.between?(60, 90 * 86_400) } && idle_timeout <= lifetime
|
|
15
|
+
raise ArgumentError, "mobile timeouts must be explicit, finite and at most 90 days"
|
|
16
|
+
end
|
|
17
|
+
unless clients.is_a?(Array) && clients.size.between?(1, 32) && clients.uniq == clients &&
|
|
18
|
+
clients.all? { |value| value.is_a?(String) && value.match?(/\A[a-z][a-z0-9_-]{0,63}\z/) }
|
|
19
|
+
raise ArgumentError, "register bounded mobile client identifiers"
|
|
20
|
+
end
|
|
21
|
+
@lifetime, @idle_timeout = lifetime, idle_timeout
|
|
22
|
+
@clients = clients.map { |client| client.dup.freeze }.freeze
|
|
23
|
+
unless callbacks.is_a?(Hash) && callbacks.all? { |client, callback| client?(client) && valid_callback?(callback) }
|
|
24
|
+
raise ArgumentError, "register exact mobile callbacks without query, fragment or credentials"
|
|
25
|
+
end
|
|
26
|
+
@callbacks = callbacks.to_h { |client, callback| [client.dup.freeze, callback.dup.freeze] }.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def client?(id) = id.is_a?(String) && clients.include?(id)
|
|
31
|
+
def callback(client_id) = @callbacks[client_id]
|
|
32
|
+
def bearer = "am1:#{SecureRandom.urlsafe_base64(32)}"
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def valid_callback?(value)
|
|
37
|
+
return false unless value.is_a?(String) && value.ascii_only? && value.bytesize.between?(10, 2048) && !value.match?(/[\x00-\x20\x7f\\%]/)
|
|
38
|
+
uri = URI.parse(value)
|
|
39
|
+
uri.scheme && !%w[http javascript data file].include?(uri.scheme.downcase) &&
|
|
40
|
+
uri.host && !uri.host.empty? && uri.path && !uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module MobileResponse
|
|
6
|
+
def self.status(reason)
|
|
7
|
+
{rate_limited: 429, challenge_unavailable: 503, challenge_rejected: 422, unconfirmed: 403, locked: 423, disabled: 403,
|
|
8
|
+
elevation_required: 403}.fetch(reason, 401)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
class NativeAuthentication
|
|
8
|
+
Challenge = Struct.new(:id, :nonce, :expires_at) do
|
|
9
|
+
def inspect = "#<AddAuth::Core::NativeAuthentication::Challenge [FILTERED]>"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(external_identities:, profile:, providers:, intake:, verify:, accounts: nil)
|
|
13
|
+
@external, @profile, @providers, @intake, @verify = external_identities, profile, providers, intake, verify
|
|
14
|
+
unless providers.is_a?(Hash) && providers.all? { |client_id, provider| profile&.client?(client_id) && provider.is_a?(ExternalIdentities::Configuration) }
|
|
15
|
+
raise ArgumentError, "register a native provider configuration for each supported client"
|
|
16
|
+
end
|
|
17
|
+
@providers = providers.dup.freeze
|
|
18
|
+
@accounts = accounts
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start(client_id:, ip:, challenge_token: nil, intent: "sign_in")
|
|
22
|
+
admitted = @intake.anonymous(ip: ip, action: (intent == "enroll") ? :register : :provider, challenge_token: challenge_token)
|
|
23
|
+
return failure(admitted) if admitted.is_a?(Symbol)
|
|
24
|
+
provider = @providers[client_id]
|
|
25
|
+
return failure unless @profile&.client?(client_id) && provider
|
|
26
|
+
purpose = {"sign_in" => ExternalIdentities::NATIVE, "enroll" => ExternalIdentities::NATIVE_ENROLL}[intent]
|
|
27
|
+
return failure unless purpose
|
|
28
|
+
return failure if intent == "enroll" && !@accounts
|
|
29
|
+
nonce = SecureRandom.urlsafe_base64(32)
|
|
30
|
+
result = @external.begin_transaction(configuration_id: provider.id, browser_secret: nonce, purpose: purpose,
|
|
31
|
+
binding_context: context(client_id))
|
|
32
|
+
return result unless result.success?
|
|
33
|
+
Result.success(user: nil, strategy: :external_identity,
|
|
34
|
+
credential: Challenge.new(id: result.credential.id, nonce: nonce, expires_at: result.credential.expires_at))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def complete(client_id:, challenge_id:, nonce:, identity_token:, ip:, user_agent: nil)
|
|
38
|
+
checked = verified(client_id: client_id, challenge_id: challenge_id, nonce: nonce, identity_token: identity_token,
|
|
39
|
+
ip: ip, purpose: ExternalIdentities::NATIVE)
|
|
40
|
+
return checked unless checked.success?
|
|
41
|
+
@external.native_sign_in(evidence: checked.credential, client_id: client_id, ip_address: hint(ip, 128), user_agent: hint(user_agent, 512))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enroll(client_id:, challenge_id:, nonce:, identity_token:, ip:, identifier:, profile: {})
|
|
45
|
+
return failure(:disabled) unless @accounts
|
|
46
|
+
checked = verified(client_id: client_id, challenge_id: challenge_id, nonce: nonce, identity_token: identity_token,
|
|
47
|
+
ip: ip, purpose: ExternalIdentities::NATIVE_ENROLL)
|
|
48
|
+
return checked unless checked.success?
|
|
49
|
+
@accounts.call.register_external(identifier: identifier, evidence: checked.credential, profile: profile)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def verified(client_id:, challenge_id:, nonce:, identity_token:, ip:, purpose:)
|
|
55
|
+
admitted = @intake.anonymous(ip: ip, action: :native_exchange)
|
|
56
|
+
return failure(admitted) if admitted.is_a?(Symbol)
|
|
57
|
+
provider = @providers[client_id]
|
|
58
|
+
return failure unless @profile&.client?(client_id) && provider && identity_token.is_a?(String) &&
|
|
59
|
+
identity_token.ascii_only? && identity_token.bytesize.between?(1, 16_384)
|
|
60
|
+
pending = @external.pending(transaction: challenge_id, browser_secret: nonce, binding_context: context(client_id))
|
|
61
|
+
return failure unless pending && pending.purpose == purpose && pending.configuration.equal?(provider)
|
|
62
|
+
result = @verify.call(identity_token: identity_token, pending: pending, nonce: nonce)
|
|
63
|
+
evidence = provider.verify(server_result: result, transaction: pending)
|
|
64
|
+
return failure unless evidence
|
|
65
|
+
Result.success(user: nil, strategy: :external_identity, credential: evidence)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def context(client_id) = "native:#{client_id}"
|
|
69
|
+
|
|
70
|
+
def failure(reason = :invalid_credentials) = Result.failure(reason: reason)
|
|
71
|
+
|
|
72
|
+
def hint(value, limit)
|
|
73
|
+
value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= limit && !value.match?(/[\x00-\x1f\x7f]/)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Passwords
|
|
8
|
+
# Explicit metadata support for the stock Rails bcrypt verifier, not an
|
|
9
|
+
# algorithm detector or authentication proof. Override verifiers supply
|
|
10
|
+
# their own available?(digest:) contract instead of using this adapter.
|
|
11
|
+
class BcryptSupport
|
|
12
|
+
PATTERN = /\A\$2[aby]\$(\d{2})\$[.\/A-Za-z0-9]{53}\z/
|
|
13
|
+
|
|
14
|
+
def initialize(maximum_cost: BCrypt::Engine::MAX_COST)
|
|
15
|
+
@maximum_cost = maximum_cost
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def available?(digest:)
|
|
19
|
+
return false unless digest.is_a?(String) && digest.valid_encoding? && digest.bytesize == 60
|
|
20
|
+
match = PATTERN.match(digest)
|
|
21
|
+
!!(match && match[1].to_i.between?(BCrypt::Engine::MIN_COST, @maximum_cost))
|
|
22
|
+
rescue ArgumentError, EncodingError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
module Core
|
|
7
|
+
module Passwords
|
|
8
|
+
class Credential
|
|
9
|
+
def self.scheme_after_assignment(scheme:, password:, digest_changed:)
|
|
10
|
+
(digest_changed && password.is_a?(String) && !password.empty?) ? "rails" : scheme
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(legacy_verifier:)
|
|
14
|
+
@legacy = legacy_verifier
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authenticate(user:, password:, current:)
|
|
18
|
+
return Result.failure(reason: :invalid_credentials) unless password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 1024)
|
|
19
|
+
verified = case scheme(user)
|
|
20
|
+
when nil, "rails" then current.call(password)
|
|
21
|
+
when "devise_bcrypt" then @legacy&.verify(digest: user.password_digest, password: password) == true
|
|
22
|
+
else false
|
|
23
|
+
end
|
|
24
|
+
verified ? Result.success(user: user, strategy: :password) : Result.failure(reason: :invalid_credentials)
|
|
25
|
+
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSecret, ArgumentError, EncodingError
|
|
26
|
+
Result.failure(reason: :invalid_credentials)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Availability is not authentication. It asks the explicitly selected
|
|
30
|
+
# verifier whether it supports this persisted credential with its current
|
|
31
|
+
# configuration. Older/custom adapters without that contract fail closed.
|
|
32
|
+
def available?(user:, current:)
|
|
33
|
+
return false unless user.respond_to?(:password_digest)
|
|
34
|
+
digest = user.password_digest
|
|
35
|
+
return false unless digest.is_a?(String) && digest.valid_encoding? && !digest.empty?
|
|
36
|
+
verifier = case scheme(user)
|
|
37
|
+
when nil, "rails" then current
|
|
38
|
+
when "devise_bcrypt" then @legacy
|
|
39
|
+
end
|
|
40
|
+
verifier.respond_to?(:available?) && verifier.available?(digest: digest) == true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def scheme(user) = user.respond_to?(:add_auth_password_scheme) ? user.add_auth_password_scheme : nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bcrypt"
|
|
4
|
+
require "add_auth/core/passwords/bcrypt_support"
|
|
5
|
+
|
|
6
|
+
module AddAuth
|
|
7
|
+
module Core
|
|
8
|
+
module Passwords
|
|
9
|
+
# Verification only. New password assignment remains the Rails host's job.
|
|
10
|
+
# The source profile chooses this adapter explicitly; no digest guessing.
|
|
11
|
+
class LegacyBcrypt
|
|
12
|
+
PATTERN = BcryptSupport::PATTERN
|
|
13
|
+
|
|
14
|
+
def initialize(pepper: -> {}, maximum_cost: 14)
|
|
15
|
+
raise ArgumentError, "pepper must be callable" unless pepper.respond_to?(:call)
|
|
16
|
+
raise ArgumentError, "maximum_cost must be between 4 and 20" unless maximum_cost.is_a?(Integer) && maximum_cost.between?(4, 20)
|
|
17
|
+
@pepper, @maximum_cost = pepper, maximum_cost
|
|
18
|
+
@support = BcryptSupport.new(maximum_cost: maximum_cost)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def available?(digest:)
|
|
22
|
+
@support.available?(digest: digest) && valid_pepper?(@pepper.call)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verify(digest:, password:)
|
|
26
|
+
return false unless password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 1024)
|
|
27
|
+
return false unless @support.available?(digest: digest)
|
|
28
|
+
pepper = @pepper.call
|
|
29
|
+
return false unless valid_pepper?(pepper)
|
|
30
|
+
BCrypt::Password.new(digest).is_password?((pepper.nil? || pepper.empty?) ? password : "#{password}#{pepper}")
|
|
31
|
+
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSecret, ArgumentError, EncodingError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inspect = "#<AddAuth::Core::Passwords::LegacyBcrypt [FILTERED]>"
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def valid_pepper?(pepper) = pepper.nil? || (pepper.is_a?(String) && pepper.valid_encoding? && pepper.bytesize <= 1024)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
module Passwords
|
|
6
|
+
# Called only inside the owning account transaction. The adapter supplies
|
|
7
|
+
# Rails hashing and persistence; these decisions are shared by entry paths.
|
|
8
|
+
class Lifecycle
|
|
9
|
+
def initialize(store:, policy:, enabled:, maximum_attempts: 20, unlock_in: 3600, clock: Time, issue_unlock: ->(_user) {})
|
|
10
|
+
unless maximum_attempts.is_a?(Integer) && maximum_attempts.between?(1, 100) &&
|
|
11
|
+
unlock_in.is_a?(Numeric) && unlock_in.finite? && unlock_in.between?(60, 86_400)
|
|
12
|
+
raise ArgumentError, "configure bounded password attempts and unlock duration"
|
|
13
|
+
end
|
|
14
|
+
@store, @policy, @enabled, @maximum, @unlock_in, @clock, @issue_unlock = store, policy, enabled, maximum_attempts, unlock_in, clock, issue_unlock
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def prepare(user:)
|
|
18
|
+
return unless @enabled && user && !user.add_auth_manual_lock && user.locked_at &&
|
|
19
|
+
user.add_auth_locked_until && user.add_auth_locked_until <= @clock.now
|
|
20
|
+
@store.update_account(user: user, locked_at: nil, add_auth_locked_until: nil, failed_attempts: 0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verified(user:, password:, valid:, rehash: true)
|
|
24
|
+
return false unless user && @policy.call(user) == true
|
|
25
|
+
if valid
|
|
26
|
+
@store.update_account(user: user, failed_attempts: 0) if @enabled && user.failed_attempts != 0
|
|
27
|
+
if rehash && user.respond_to?(:add_auth_password_scheme) && user.add_auth_password_scheme == "devise_bcrypt" && assignable?(password)
|
|
28
|
+
@store.rehash_password(user: user, password: password)
|
|
29
|
+
@store.revoke_authority(user: user, at: @clock.now)
|
|
30
|
+
end
|
|
31
|
+
true
|
|
32
|
+
else
|
|
33
|
+
record_failure(user) if @enabled
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def assignable?(password)
|
|
41
|
+
password.is_a?(String) && password.valid_encoding? && password.bytesize.between?(1, 72) && !password.include?("\0")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record_failure(user)
|
|
45
|
+
attempts = [user.failed_attempts.to_i + 1, @maximum].min
|
|
46
|
+
changes = {failed_attempts: attempts}
|
|
47
|
+
if attempts >= @maximum
|
|
48
|
+
changes[:locked_at] = @clock.now
|
|
49
|
+
changes[:add_auth_locked_until] = @clock.now + @unlock_in
|
|
50
|
+
end
|
|
51
|
+
@store.update_account(user: user, **changes)
|
|
52
|
+
if attempts >= @maximum
|
|
53
|
+
@store.revoke_authority(user: user, at: @clock.now)
|
|
54
|
+
@issue_unlock.call(user)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module Core
|
|
5
|
+
# Evaluate against a reloaded account under the credential mutation lock.
|
|
6
|
+
# Counts must include only this account's live credentials. Feature flags
|
|
7
|
+
# and method labels alone do not establish a usable remaining factor.
|
|
8
|
+
class RemainingFactors
|
|
9
|
+
def initialize(access_policy:, passkey_count:, password_available:)
|
|
10
|
+
@access, @passkey_count, @password_available = access_policy, passkey_count, password_available
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(user)
|
|
14
|
+
return true if @access.sign_in_allowed?(user, :passkey) && positive_count?(@passkey_count.call(user))
|
|
15
|
+
return true if @access.recoverable?(user)
|
|
16
|
+
@access.sign_in_allowed?(user, :password) && @password_available.call(user) == true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def positive_count?(value) = value.is_a?(Integer) && value.positive?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|