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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class SessionsController < ::ApplicationController
|
|
5
|
+
include Rails::AuthenticationPages
|
|
6
|
+
|
|
7
|
+
rescue_from AddAuth::Error, with: :service_unavailable
|
|
8
|
+
helper AddAuth::SignInsHelper
|
|
9
|
+
before_action :require_add_auth_authentication
|
|
10
|
+
before_action :private_response
|
|
11
|
+
protect_from_forgery with: :exception
|
|
12
|
+
layout "application"
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@session_page = Rails::Runtime.sessions.list_page(user: Current.user,
|
|
16
|
+
current_session_id: Current.session.id, before: params[:before])
|
|
17
|
+
@sessions = @session_page.entries
|
|
18
|
+
respond_to do |format|
|
|
19
|
+
format.html { render "add_auth/sessions/index" }
|
|
20
|
+
format.turbo_stream do
|
|
21
|
+
render turbo_stream: turbo_stream.update("add_auth-session-content", partial: "add_auth/sessions/list")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def destroy
|
|
27
|
+
target_id = params[:id].to_s
|
|
28
|
+
if target_id.match?(/\A[1-9]\d*\z/) && target_id.to_i == Current.session.id
|
|
29
|
+
terminate_session
|
|
30
|
+
return redirect_to Rails::Runtime.sign_in_path, status: :see_other
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
revoked = Rails::Runtime.sessions.revoke_one(user: Current.user, session: Current.session, session_id: target_id)
|
|
34
|
+
return head :not_found unless revoked
|
|
35
|
+
|
|
36
|
+
redirect_to security_sessions_path, status: :see_other
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def new_revoke_all
|
|
40
|
+
@elevated = Rails::Runtime.sessions.with_elevation(user: Current.user, session: Current.session,
|
|
41
|
+
purpose: :sign_out_everywhere, policy: Rails::Runtime.step_up_policy).success?
|
|
42
|
+
@password_available = Rails::Runtime.access_policy.methods_for(Current.user).include?(:password)
|
|
43
|
+
page("revoke_all")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def revoke_all
|
|
47
|
+
authorization = Rails::Runtime.revoke_all(user: Current.user, session: Current.session,
|
|
48
|
+
password: params[:password], ip: request.remote_ip, challenge_token: challenge_token)
|
|
49
|
+
return intake_failure(authorization.reason) unless authorization.success?
|
|
50
|
+
|
|
51
|
+
terminate_session
|
|
52
|
+
redirect_to Rails::Runtime.sign_in_path, status: :see_other
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def authentication_partial(_partial)
|
|
58
|
+
@password_available = Rails::Runtime.access_policy.methods_for(Current.user).include?(:password)
|
|
59
|
+
"add_auth/sessions/revoke_all"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class SignInsController < ::ApplicationController
|
|
5
|
+
include Rails::AuthenticationPages
|
|
6
|
+
|
|
7
|
+
skip_before_action :require_authentication, raise: false
|
|
8
|
+
layout "add_auth/authentication"
|
|
9
|
+
helper AddAuth::SignInsHelper
|
|
10
|
+
before_action :private_response
|
|
11
|
+
protect_from_forgery with: :exception
|
|
12
|
+
rescue_from AddAuth::Error, "ActiveJob::EnqueueError", with: :service_unavailable
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
page("form")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def password = add_auth_password_sign_in
|
|
19
|
+
|
|
20
|
+
def request_link
|
|
21
|
+
return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
|
|
22
|
+
intake = Rails::Runtime.intake.call(identifier: params[:email_address], ip: request.remote_ip,
|
|
23
|
+
action: :email_link, challenge_token: challenge_token)
|
|
24
|
+
return intake_failure(intake) if %i[challenge_rejected challenge_unavailable].include?(intake)
|
|
25
|
+
unless intake.is_a?(Symbol)
|
|
26
|
+
session[:add_auth_browser] ||= Rails::Runtime.browser_binding.generate
|
|
27
|
+
Rails::Runtime.enqueue_email(intake, browser_secret: session[:add_auth_browser])
|
|
28
|
+
end
|
|
29
|
+
redirect_to "/sign-in/check-email", status: :see_other
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check_email
|
|
33
|
+
return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
|
|
34
|
+
page("check_email")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def link
|
|
38
|
+
return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
|
|
39
|
+
@preview = Rails::Runtime.email.preview(token: params[:token], browser_secret: session[:add_auth_browser])
|
|
40
|
+
# GET does not resume/upgrade an existing session or consume this link.
|
|
41
|
+
page(Rails::Runtime.email.confirmation_page(@preview))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def confirm
|
|
45
|
+
return head :not_found unless Core::Intake.email_available?(Rails::Runtime.config)
|
|
46
|
+
previous = add_auth_replacement_session
|
|
47
|
+
grant = nil
|
|
48
|
+
result = Rails::Runtime.email.consume(token: params[:token], current_session: previous,
|
|
49
|
+
switch_account: params[:switch_account] == "1", browser_secret: session[:add_auth_browser]) do |user, persist|
|
|
50
|
+
grant = Rails::Runtime.sessions.create_in_transaction(user: user, method: :email_link, persist: persist,
|
|
51
|
+
replacing: previous, **add_auth_session_hints)
|
|
52
|
+
grant&.session
|
|
53
|
+
end
|
|
54
|
+
if result.success?
|
|
55
|
+
destination = after_authentication_url
|
|
56
|
+
add_auth_accept(grant)
|
|
57
|
+
redirect_to destination, status: :see_other
|
|
58
|
+
else
|
|
59
|
+
page("invalid_link", status: :unprocessable_entity)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
module SignInsHelper
|
|
5
|
+
def add_auth_stylesheet
|
|
6
|
+
path = AddAuth.configuration.stylesheet
|
|
7
|
+
raise AddAuth::Error, "authentication stylesheets must use a same-origin path" if path && !Core::Sessions.safe_return(path)
|
|
8
|
+
path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def add_auth_class(role)
|
|
12
|
+
AddAuth.configuration.css_classes.fetch(role, "add_auth-#{role}")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add_auth_challenge_form_data(action)
|
|
16
|
+
data = {turbo_frame: "_top"}
|
|
17
|
+
return data unless AddAuth.configuration.challenge_on.include?(action)
|
|
18
|
+
provider = AddAuth.configuration.challenge
|
|
19
|
+
return data unless provider.site_key
|
|
20
|
+
kind = case provider
|
|
21
|
+
when Core::Challenge::Turnstile then "turnstile"
|
|
22
|
+
when Core::Challenge::Recaptcha then "recaptcha-#{provider.version}"
|
|
23
|
+
else return data
|
|
24
|
+
end
|
|
25
|
+
data.merge(controller: "add-auth-challenge",
|
|
26
|
+
action: "submit->add-auth-challenge#submit add_auth:proof-used->add-auth-challenge#reset turbo:submit-end->add-auth-challenge#reset turbo:before-cache@document->add-auth-challenge#beforeCache",
|
|
27
|
+
add_auth_challenge_provider_value: kind, add_auth_challenge_site_key_value: provider.site_key,
|
|
28
|
+
add_auth_challenge_action_value: (provider.respond_to?(:expected_action) && provider.expected_action) || action,
|
|
29
|
+
add_auth_challenge_script_url_value: provider.script_url)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_auth_challenge_tag(action, id: action)
|
|
33
|
+
return safe_join([]) unless AddAuth.configuration.challenge_on.include?(action)
|
|
34
|
+
safe_join([
|
|
35
|
+
hidden_field_tag(:challenge_token, nil, id: "#{id}_challenge_token", data: {add_auth_challenge_target: "token"}),
|
|
36
|
+
tag.div(hidden: true, data: {add_auth_challenge_target: "widget"}),
|
|
37
|
+
tag.p("Verification is loading.", hidden: true, role: "status", class: add_auth_class(:notice),
|
|
38
|
+
data: {add_auth_challenge_target: "status"})
|
|
39
|
+
])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/smtp"
|
|
4
|
+
|
|
5
|
+
module AddAuth
|
|
6
|
+
class DeliveryJob < ActiveJob::Base
|
|
7
|
+
self.log_arguments = false
|
|
8
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 8
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def deliver(service:, record:, mailer:)
|
|
13
|
+
delivery = service.claim_delivery(digest: record.digest)
|
|
14
|
+
return unless delivery
|
|
15
|
+
begin
|
|
16
|
+
message = yield delivery
|
|
17
|
+
raise AddAuth::Error, "mail delivery is disabled" unless mailer.perform_deliveries
|
|
18
|
+
message.deliver_now
|
|
19
|
+
outcome = message.add_auth_delivery_completed? ? :delivered : :cancelled
|
|
20
|
+
if service.finish_delivery(digest: record.digest, lease: delivery.fetch(:lease), outcome: outcome) && outcome == :cancelled
|
|
21
|
+
ActiveSupport::Notifications.instrument("delivery_cancelled.add_auth", issuance_id: record.id)
|
|
22
|
+
end
|
|
23
|
+
rescue Net::SMTPFatalError
|
|
24
|
+
service.delivery_failed(digest: record.digest, lease: delivery.fetch(:lease))
|
|
25
|
+
rescue
|
|
26
|
+
service.delivery_retry(digest: record.digest, lease: delivery.fetch(:lease))
|
|
27
|
+
raise
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class EmailDeliveryJob < DeliveryJob
|
|
5
|
+
def perform(id)
|
|
6
|
+
record = ::AddAuthSignInToken.find_by(id: id)
|
|
7
|
+
return unless record
|
|
8
|
+
service = Rails::Runtime.email(purpose: record.purpose)
|
|
9
|
+
deliver(service: service, record: record, mailer: SignInMailer) do |delivery|
|
|
10
|
+
SignInMailer.link(recipient: delivery.fetch(:recipient),
|
|
11
|
+
url: Rails::Runtime.sign_in_url(delivery.fetch(:token), purpose: record.purpose), purpose: record.purpose)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class EmailRequestJob < ActiveJob::Base
|
|
5
|
+
self.log_arguments = false
|
|
6
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 8
|
|
7
|
+
|
|
8
|
+
def perform(encrypted_identifier)
|
|
9
|
+
payload = Rails::Runtime.decrypt_intake(encrypted_identifier)
|
|
10
|
+
# Continue accepting encrypted identifier-only jobs from the prior deploy.
|
|
11
|
+
identifier = payload.is_a?(Hash) ? payload["identifier"] : payload
|
|
12
|
+
browser_digest = payload["browser_digest"] if payload.is_a?(Hash)
|
|
13
|
+
# Dispatch an already committed intent even after the intake has expired.
|
|
14
|
+
purpose = payload.is_a?(Hash) ? payload.fetch("purpose", "sign_in") : "sign_in"
|
|
15
|
+
context = payload.is_a?(Hash) ? payload.slice("session_id", "session_digest", "authentication_purpose").symbolize_keys : {}
|
|
16
|
+
Rails::Runtime.email(purpose: purpose).issue(identifier: identifier, request_id: job_id, browser_digest: browser_digest, **context) if identifier
|
|
17
|
+
record = ::AddAuthSignInToken.find_by(request_id: job_id)
|
|
18
|
+
EmailDeliveryJob.perform_later(record.id) if record
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class SecurityNotificationJob < DeliveryJob
|
|
5
|
+
def perform(id)
|
|
6
|
+
record = ::AddAuthSecurityEvent.find_by(id: id)
|
|
7
|
+
return unless record
|
|
8
|
+
deliver(service: Rails::Runtime.security_events, record: record, mailer: SecurityMailer) do |delivery|
|
|
9
|
+
SecurityMailer.notice(recipient: delivery.fetch(:recipient), kind: delivery.fetch(:kind))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class SecurityMailer < SignInMailer
|
|
5
|
+
def notice(recipient:, kind:)
|
|
6
|
+
@description = {
|
|
7
|
+
"password_changed" => "Your password changed.", "email_changed" => "Your account email address changed.",
|
|
8
|
+
"passkey_added" => "A passkey was added to your account.", "passkey_removed" => "A passkey was removed from your account.",
|
|
9
|
+
"policy_changed" => "Your account sign-in and recovery policy changed.", "recovery_completed" => "Passkey recovery completed on your account."
|
|
10
|
+
}.fetch(kind.to_s)
|
|
11
|
+
from = Rails::Runtime.config.mail_from
|
|
12
|
+
raise AddAuth::Error, "configure mail_from before delivering security notifications" if from.to_s.empty?
|
|
13
|
+
mail(to: recipient, from: from, subject: "Account security change", content_type: "text/plain").extend(DeliveryReceipt)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AddAuth
|
|
4
|
+
class SignInMailer < ActionMailer::Base
|
|
5
|
+
module DeliveryReceipt
|
|
6
|
+
def add_auth_delivery_completed? = @add_auth_delivery_completed == true
|
|
7
|
+
def add_auth_delivery_completed! = @add_auth_delivery_completed = true
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Action Mailer's default instrumentation includes the entire bearer-bearing
|
|
11
|
+
# message in debug logs. Keep this mailer's delivery event secret-free.
|
|
12
|
+
def self.deliver_mail(mail)
|
|
13
|
+
# Interceptors have already run. Suppression is cancellation, and errors
|
|
14
|
+
# must propagate rather than silently turning a failed transport into success.
|
|
15
|
+
return unless mail.perform_deliveries
|
|
16
|
+
raise AddAuth::Error, "sign-in mail must report delivery errors" unless mail.raise_delivery_errors
|
|
17
|
+
ActiveSupport::Notifications.instrument("deliver.add_auth", message_id: mail.message_id) { yield }
|
|
18
|
+
mail.add_auth_delivery_completed!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
self.raise_delivery_errors = true
|
|
22
|
+
|
|
23
|
+
def link(recipient:, url:, purpose: "sign_in")
|
|
24
|
+
from = Rails::Runtime.config.mail_from
|
|
25
|
+
raise AddAuth::Error, "configure mail_from before delivering sign-in links" if from.to_s.empty?
|
|
26
|
+
subject, instruction, minutes = {
|
|
27
|
+
"sign_in" => ["Your sign-in link", "Open this link, then choose Sign in to confirm your account", Rails::Runtime.config.email_link.token_lifetime / 60],
|
|
28
|
+
"reauthentication" => ["Verify your current session", "Open this link in the signed-in browser where you started, then confirm verification", 5],
|
|
29
|
+
"recovery" => ["Recover your passkeys", "Open this link, then explicitly confirm that you want to recover your passkeys", 20]
|
|
30
|
+
}.fetch(purpose.to_s)
|
|
31
|
+
@url, @instruction, @minutes = url, instruction, minutes
|
|
32
|
+
# No token-bearing mail body in Action Mailer's debug logs.
|
|
33
|
+
mail(to: recipient, from: from, subject: subject, content_type: "text/plain").extend(DeliveryReceipt)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
File without changes
|
data/app/views/.keep
ADDED
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div data-controller="add-auth-passkey"
|
|
2
|
+
data-add-auth-passkey-options-url-value="<%= options_url %>"
|
|
3
|
+
data-add-auth-passkey-finish-url-value="<%= finish_url %>"
|
|
4
|
+
data-add-auth-passkey-mode-value="<%= mode %>"
|
|
5
|
+
data-add-auth-passkey-purpose-value="<%= local_assigns[:purpose] %>"
|
|
6
|
+
data-add-auth-passkey-conditional-value="<%= local_assigns.fetch(:conditional, false) && !AddAuth.configuration.challenge_on.include?(action) %>"
|
|
7
|
+
data-add-auth-passkey-csrf-value="<%= form_authenticity_token %>"
|
|
8
|
+
data-action="turbo:before-cache@document->add-auth-passkey#beforeCache submit@document->add-auth-passkey#otherSubmission">
|
|
9
|
+
<p data-add-auth-passkey-target="unavailable">Passkeys need JavaScript and a compatible browser or device. Use another method allowed by your account policy if available.</p>
|
|
10
|
+
<%= form_with url: options_url, data: add_auth_challenge_form_data(action).merge(add_auth_passkey_target: "form", action: [add_auth_challenge_form_data(action)[:action], "submit->add-auth-passkey#submit"].compact.join(" ")) do |form| %>
|
|
11
|
+
<%= add_auth_challenge_tag action, id: "passkey_#{action}" %>
|
|
12
|
+
<div hidden data-add-auth-passkey-target="controls">
|
|
13
|
+
<%= form.submit label, class: add_auth_class(:button), data: {add_auth_challenge_target: "submit", add_auth_passkey_target: "button"} %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
<p role="status" aria-live="polite" tabindex="-1" hidden data-add-auth-passkey-target="status"></p>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Your passkeys</h1>
|
|
2
|
+
<% if @error %><p role="alert"><%= @error %></p><% end %>
|
|
3
|
+
<p>Add a passkey for this browser, another device, or a security key. First verify your account when prompted.</p>
|
|
4
|
+
<%= render "add_auth/passkeys/controls", mode: "create", options_url: "/passkeys/options", finish_url: "/passkeys", label: "Add a passkey", action: :passkey_enrollment %>
|
|
5
|
+
<% @credentials.each do |credential| %>
|
|
6
|
+
<section id="passkey_<%= credential.id %>">
|
|
7
|
+
<h2><%= credential.nickname %></h2>
|
|
8
|
+
<p>Added <%= credential.created_at.strftime("%Y-%m-%d") %><% if credential.last_used_at %> · Last used <%= credential.last_used_at.strftime("%Y-%m-%d") %><% end %></p>
|
|
9
|
+
<% if credential.backup_eligible %><p>Supports backup. This does not prove you have a separately usable device.</p><% end %>
|
|
10
|
+
<%= form_with url: "/passkeys/#{credential.id}", method: :patch, data: {turbo_frame: "_top"} do |form| %>
|
|
11
|
+
<%= form.label :nickname, "Passkey name", for: "nickname_#{credential.id}" %>
|
|
12
|
+
<%= form.text_field :nickname, id: "nickname_#{credential.id}", value: credential.nickname, maxlength: 60, required: true %>
|
|
13
|
+
<%= form.submit "Rename" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
<details>
|
|
16
|
+
<summary>Remove this passkey</summary>
|
|
17
|
+
<p>This removes access to this account. You may also delete the saved passkey from your password manager afterward. Keep another usable sign-in method.</p>
|
|
18
|
+
<%= button_to "Confirm removal", "/passkeys/#{credential.id}", method: :delete, form: {data: {turbo_frame: "_top"}} %>
|
|
19
|
+
</details>
|
|
20
|
+
</section>
|
|
21
|
+
<% end %>
|
|
22
|
+
<h2>Recovery policy</h2>
|
|
23
|
+
<p><%= @strict ? "Strict: passkeys are required. Password reset and email cannot replace a lost passkey." : "Default: enabled methods can sign in. Email replacement is available only through an established, trusted recovery address." %></p>
|
|
24
|
+
<p>Keep a separately usable passkey and test it before relying on strict policy. Two synced entries may share the same failure point.</p>
|
|
25
|
+
<%= form_with url: "/passkeys/policy", data: {turbo_frame: "_top"} do |form| %>
|
|
26
|
+
<%= form.hidden_field :policy, value: @strict ? "default" : "strict" %>
|
|
27
|
+
<%= form.check_box :acknowledged, required: true %>
|
|
28
|
+
<%= form.label :acknowledged, "I understand the recovery limits and have tested another way to access my account." %>
|
|
29
|
+
<%= form.submit @strict ? "Use default recovery policy" : "Require passkeys for this account" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<p><%= link_to "Verify to change policy", "/reauthenticate?purpose=manage_policy" %></p>
|
|
32
|
+
<p><%= link_to "I can’t use my passkeys", "/recover" %> · <%= link_to "Your sessions", "/sessions" %></p>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Check your email</h1>
|
|
2
|
+
<p>If this verification is permitted, we’ve sent a link. Use the newest email within five minutes and open it in this browser.</p>
|
|
3
|
+
<%= link_to "Try again or use another method", "/reauthenticate?#{URI.encode_www_form(purpose: @purpose)}", class: add_auth_class(:link) %>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Confirm this verification</h1>
|
|
2
|
+
<p>Verify <strong><%= @preview.fetch(:masked_address) %></strong> in the browser where you started. You’ll return to review the action before submitting it.</p>
|
|
3
|
+
<%= form_with url: "/reauthenticate/link", data: {turbo_frame: "_top"} do |form| %>
|
|
4
|
+
<%= form.hidden_field :token, value: params[:token] %>
|
|
5
|
+
<%= form.submit "Confirm verification", class: add_auth_class(:button) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= link_to "Cancel", "/", class: add_auth_class(:link) %>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Return to the browser where you started</h1>
|
|
2
|
+
<p role="status">This verification can only continue in the signed-in browser that requested it. Copy the link into that browser. Opening it here does not verify another device.</p>
|
|
3
|
+
<%= link_to "Cancel", "/", class: add_auth_class(:link) %>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Verify it’s you</h1>
|
|
2
|
+
<p>Confirm your identity to <%= @rule&.label || "continue" %>.</p>
|
|
3
|
+
<% if @error %><p role="alert"><%= @error %></p><% end %>
|
|
4
|
+
<% if Array(@methods).include?(:password) %>
|
|
5
|
+
<%= form_with url: "/reauthenticate/password", data: add_auth_challenge_form_data(:reauthenticate) do |form| %>
|
|
6
|
+
<%= form.hidden_field :purpose, value: @purpose %>
|
|
7
|
+
<div class="<%= add_auth_class(:field) %>">
|
|
8
|
+
<%= form.label :password, "Current password" %>
|
|
9
|
+
<%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
|
|
10
|
+
</div>
|
|
11
|
+
<%= add_auth_challenge_tag :reauthenticate, id: :password %>
|
|
12
|
+
<%= form.submit "Verify with password", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% if Array(@methods).include?(:email_link) %>
|
|
16
|
+
<%= form_with url: "/reauthenticate/email", data: add_auth_challenge_form_data(:reauthenticate) do |form| %>
|
|
17
|
+
<%= form.hidden_field :purpose, value: @purpose %>
|
|
18
|
+
<%= add_auth_challenge_tag :reauthenticate, id: :email %>
|
|
19
|
+
<%= form.submit "Email me a verification link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if Array(@methods).empty? %><p role="status">No permitted verification method is available here. Contact your account administrator for an allowed way to continue.</p><% end %>
|
|
23
|
+
<p><%= link_to "Cancel", @rule&.return_to || "/", class: add_auth_class(:link) %></p>
|
|
24
|
+
|
|
25
|
+
<% if Array(@methods).include?(:passkey) %>
|
|
26
|
+
<%= render "add_auth/passkeys/controls", mode: "get", options_url: "/reauthenticate/passkey/options", finish_url: "/reauthenticate/passkey", label: "Verify with a passkey", action: :reauthenticate, purpose: @purpose %>
|
|
27
|
+
<% end %>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Start verification again</h1>
|
|
2
|
+
<p role="alert">This link cannot verify your session. It may have expired, been used, or belong to another browser. Return to your original action to start again.</p>
|
|
3
|
+
<%= link_to "Return to your account", "/", class: add_auth_class(:link) %>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Check your email</h1>
|
|
2
|
+
<p>If this account can use email recovery, we’ve sent a link. Use the newest link within twenty minutes. Strict accounts need another passkey or account support.</p>
|
|
3
|
+
<%= link_to "Try again", "/recover" %> · <%= link_to "Back to sign in", "/sign-in" %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Confirm passkey recovery</h1>
|
|
2
|
+
<p>Recover access to <strong><%= @preview.fetch(:masked_address) %></strong>. This replaces any account signed in on this browser. You’ll have ten minutes to add a replacement passkey.</p>
|
|
3
|
+
<p>Existing passkeys stay active until you explicitly review and remove them. Other sessions are revoked only after replacement succeeds.</p>
|
|
4
|
+
<%= form_with url: "/recover/link", data: {turbo_frame: "_top"} do |form| %>
|
|
5
|
+
<%= form.hidden_field :token, value: params[:token] %>
|
|
6
|
+
<%= form.hidden_field :switch_account, value: "1" %>
|
|
7
|
+
<%= form.submit "Continue recovering this account" %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<%= link_to "Cancel", "/sign-in" %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Recover your passkeys</h1>
|
|
2
|
+
<p>Request a replacement link at your established recovery address. Accounts using strict policy require another passkey or your account administrator’s documented recovery process.</p>
|
|
3
|
+
<% if @error %><p role="alert"><%= @error %></p><% end %>
|
|
4
|
+
<%= form_with url: "/recover/email", data: add_auth_challenge_form_data(:email_link) do |form| %>
|
|
5
|
+
<%= form.label :email_address, "Recovery email address" %>
|
|
6
|
+
<%= form.email_field :email_address, required: true, maxlength: 254, autocomplete: "email" %>
|
|
7
|
+
<%= add_auth_challenge_tag :email_link %>
|
|
8
|
+
<%= form.submit "Send recovery link", data: {add_auth_challenge_target: "submit"} %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<p><%= link_to "Back to sign in", "/sign-in" %></p>
|
|
11
|
+
<% if AddAuth.configuration.support_url %><p><%= link_to "Contact account support", AddAuth.configuration.support_url %></p><% end %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<ul class="add_auth-session-list">
|
|
2
|
+
<% @sessions.each do |session| %>
|
|
3
|
+
<li class="add_auth-session" id="session_<%= session.id %>">
|
|
4
|
+
<div>
|
|
5
|
+
<strong><%= session.current ? "This browser" : "Signed-in browser" %></strong>
|
|
6
|
+
<p class="<%= add_auth_class(:muted) %>">
|
|
7
|
+
<%= session.authenticated_with ? "Signed in with #{session.authenticated_with.tr("_", " ")}" : "Imported session" %>
|
|
8
|
+
· <%= session.last_seen_at ? "Last active #{session.last_seen_at.strftime("%Y-%m-%d %H:%M UTC")}" : "Activity unknown" %>
|
|
9
|
+
</p>
|
|
10
|
+
<% if session.user_agent.present? %><p class="<%= add_auth_class(:muted) %>"><%= session.user_agent %></p><% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<%= button_to(session.current ? "Sign out" : "Revoke", security_session_path(session.id), method: :delete,
|
|
13
|
+
class: add_auth_class(:button),
|
|
14
|
+
form: {data: {turbo_frame: "_top"}}) %>
|
|
15
|
+
</li>
|
|
16
|
+
<% end %>
|
|
17
|
+
</ul>
|
|
18
|
+
<nav aria-label="Session pages">
|
|
19
|
+
<% if params[:before].present? %>
|
|
20
|
+
<%= link_to "Newest sessions", security_sessions_path %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if @session_page.next_cursor %>
|
|
23
|
+
<%= link_to "Older sessions", security_sessions_path(before: @session_page.next_cursor) %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</nav>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<% content_for :title, "Sign out everywhere" %>
|
|
2
|
+
<h1 id="add_auth-title">Sign out everywhere</h1>
|
|
3
|
+
<p class="<%= add_auth_class(:muted) %>">Revoke every active session, including this browser. This requires a recent verification.</p>
|
|
4
|
+
<% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
|
|
5
|
+
<% if @elevated || @password_available != false %>
|
|
6
|
+
<%= form_with url: "/sessions/revoke-all", data: add_auth_challenge_form_data(:reauthenticate) do |form| %>
|
|
7
|
+
<% unless @elevated %>
|
|
8
|
+
<div class="<%= add_auth_class(:field) %>">
|
|
9
|
+
<%= form.label :password, "Current password" %>
|
|
10
|
+
<%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= add_auth_challenge_tag :reauthenticate %>
|
|
14
|
+
<%= form.submit "Sign out everywhere", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% if AddAuth.configuration.step_up.enabled %><p><%= link_to "Verify with an allowed method", "/reauthenticate?purpose=sign_out_everywhere" %></p><% end %>
|
|
18
|
+
<p><%= link_to "Cancel", "/sessions", class: add_auth_class(:link) %></p>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<% content_for :title, "Sessions" %>
|
|
2
|
+
<% content_for :head do %>
|
|
3
|
+
<% turbo_exempts_page_from_cache %>
|
|
4
|
+
<% turbo_page_requires_reload if request.headers["Turbo-Frame"].present? %>
|
|
5
|
+
<%= stylesheet_link_tag add_auth_stylesheet, skip_pipeline: true if add_auth_stylesheet %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<main class="<%= add_auth_class(:panel) %>" aria-labelledby="add_auth-title">
|
|
8
|
+
<h1 id="add_auth-title">Your sessions</h1>
|
|
9
|
+
<p class="<%= add_auth_class(:muted) %>">Review browsers signed in to your account and revoke any you no longer use.</p>
|
|
10
|
+
<p><%= link_to "Sign out everywhere", "/sessions/revoke-all", class: add_auth_class(:link) %></p>
|
|
11
|
+
<div id="add_auth-session-content"><%= render "add_auth/sessions/list" %></div>
|
|
12
|
+
</main>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Check your email</h1>
|
|
2
|
+
<p role="status">If this address can sign in, a link will arrive shortly.</p>
|
|
3
|
+
<p class="<%= add_auth_class(:muted) %>">Use the newest email. Open its link, then confirm to sign in on that device.</p>
|
|
4
|
+
<p>Nothing yet? Check your spam folder or wait a few minutes before trying again.</p>
|
|
5
|
+
<%= link_to "Return to sign in", "/sign-in", class: add_auth_class(:link) %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Confirm your sign-in</h1>
|
|
2
|
+
<p>Sign in as <strong><%= @preview.fetch(:masked_address) %></strong> on this device.</p>
|
|
3
|
+
<p class="<%= add_auth_class(:muted) %>">This will replace any account currently signed in on this browser.</p>
|
|
4
|
+
<%= form_with url: "/sign-in/link", data: {turbo_frame: "_top"} do |form| %>
|
|
5
|
+
<%= form.hidden_field :token, value: params[:token] %>
|
|
6
|
+
<%= form.hidden_field :switch_account, value: "1" %>
|
|
7
|
+
<%= form.submit "Sign in to this account", class: add_auth_class(:button) %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<p><%= link_to "Cancel", "/sign-in", class: add_auth_class(:link) %></p>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Open this link in the requesting browser</h1>
|
|
2
|
+
<p role="status">This sign-in link is tied to the browser that requested it. Copy the link into that browser, or request a new link here.</p>
|
|
3
|
+
<%= link_to "Request a link here", "/sign-in", class: add_auth_class(:link) %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<h1 id="add_auth-title">Sign in</h1>
|
|
2
|
+
<p class="<%= add_auth_class(:muted) %>">Choose an available sign-in method.</p>
|
|
3
|
+
<% if @error %><p class="<%= add_auth_class(:notice) %>" role="alert"><%= @error %></p><% end %>
|
|
4
|
+
<% if AddAuth.configuration.passwords_enabled %>
|
|
5
|
+
<%= form_with url: "/sign-in/password", id: "add_auth-password-form", data: add_auth_challenge_form_data(:sign_in) do |form| %>
|
|
6
|
+
<div class="<%= add_auth_class(:field) %>">
|
|
7
|
+
<%= form.label :email_address, "Email address" %>
|
|
8
|
+
<%= form.email_field :email_address, required: true, autocomplete: "username webauthn", value: (params[:email_address] if params[:email_address].is_a?(String)), maxlength: 254, class: add_auth_class(:input) %>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="<%= add_auth_class(:field) %>">
|
|
11
|
+
<%= form.label :password, "Password" %>
|
|
12
|
+
<%= form.password_field :password, required: true, autocomplete: "current-password", class: add_auth_class(:input) %>
|
|
13
|
+
</div>
|
|
14
|
+
<%= add_auth_challenge_tag :sign_in %>
|
|
15
|
+
<%= form.submit "Sign in with password", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if AddAuth::Core::Intake.email_available?(AddAuth.configuration) %>
|
|
19
|
+
<hr class="<%= add_auth_class(:divider) %>">
|
|
20
|
+
<%= form_with url: "/sign-in/email", id: "add_auth-email-form", data: add_auth_challenge_form_data(:email_link) do |form| %>
|
|
21
|
+
<div class="<%= add_auth_class(:field) %>">
|
|
22
|
+
<%= form.label :email_address, "Email me a sign-in link", for: "link_email_address" %>
|
|
23
|
+
<%= form.email_field :email_address, id: "link_email_address", value: (params[:email_address] if params[:email_address].is_a?(String)), required: true, autocomplete: "email", maxlength: 254, class: add_auth_class(:input) %>
|
|
24
|
+
</div>
|
|
25
|
+
<%= add_auth_challenge_tag :email_link %>
|
|
26
|
+
<%= form.submit "Send sign-in link", class: add_auth_class(:button), data: {add_auth_challenge_target: "submit"} %>
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if AddAuth.configuration.passkeys.enabled %>
|
|
32
|
+
<%= render "add_auth/passkeys/controls", mode: "get", options_url: "/passkeys/sign-in/options", finish_url: "/passkeys/sign-in", label: "Sign in with a passkey", action: :sign_in, conditional: true %>
|
|
33
|
+
<p><%= link_to "I can’t use my passkeys", "/recover" %></p>
|
|
34
|
+
<% end %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= render @page_partial %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Sign in</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<% turbo_exempts_page_from_cache %>
|
|
8
|
+
<% turbo_page_requires_reload if request.headers["Turbo-Frame"].present? %>
|
|
9
|
+
<%= yield :head %>
|
|
10
|
+
<%= csrf_meta_tags %>
|
|
11
|
+
<script src="/add_auth/boot.js" type="module" data-turbo-track="reload"></script>
|
|
12
|
+
<%= stylesheet_link_tag add_auth_stylesheet, skip_pipeline: true if add_auth_stylesheet %>
|
|
13
|
+
</head>
|
|
14
|
+
<body class="<%= add_auth_class(:body) %>">
|
|
15
|
+
<main class="<%= add_auth_class(:panel) %>" aria-labelledby="add_auth-title">
|
|
16
|
+
<div id="add_auth-content"><%= yield %></div>
|
|
17
|
+
</main>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|