authnz_eleven 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/lib/authnz_eleven/identity.rb +210 -0
- data/lib/authnz_eleven/principal.rb +201 -0
- data/lib/authnz_eleven/principals.rb +111 -0
- data/lib/authnz_eleven/version.rb +5 -0
- data/lib/authnz_eleven/wizard.rb +570 -0
- data/lib/authnz_eleven.rb +15 -0
- data/lib/generators/authnz_eleven/USAGE +26 -0
- data/lib/generators/authnz_eleven/authnz_eleven_generator.rb +3040 -0
- data/lib/generators/authnz_eleven/templates/config/forbidden_usernames.LICENSE +22 -0
- data/lib/generators/authnz_eleven/templates/config/forbidden_usernames.txt +558 -0
- data/lib/generators/authnz_eleven/templates/config/initializers/authentication.rb.tt +275 -0
- data/lib/generators/authnz_eleven/templates/config/initializers/omniauth.rb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/config/initializers/team_slug.rb.tt +24 -0
- data/lib/generators/authnz_eleven/templates/config/initializers/webauthn.rb.tt +14 -0
- data/lib/generators/authnz_eleven/templates/config/locales/authentication.en.yml.tt +11 -0
- data/lib/generators/authnz_eleven/templates/controllers/active_teams_controller.rb.tt +6 -0
- data/lib/generators/authnz_eleven/templates/controllers/admin/dashboard_controller.rb.tt +4 -0
- data/lib/generators/authnz_eleven/templates/controllers/admin/sessions_controller.rb.tt +17 -0
- data/lib/generators/authnz_eleven/templates/controllers/admin/users_controller.rb.tt +91 -0
- data/lib/generators/authnz_eleven/templates/controllers/admin_controller.rb.tt +6 -0
- data/lib/generators/authnz_eleven/templates/controllers/base_controller.rb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/api_token_authentication.rb.tt +21 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/easy_dev_login.rb.tt +47 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/registering.rb.tt +25 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/sudoable.rb.tt +117 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/webauthn_ceremony.rb.tt +51 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication.rb.tt +421 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/authorization.rb.tt +48 -0
- data/lib/generators/authnz_eleven/templates/controllers/concerns/captchable.rb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/controllers/impersonations_controller.rb.tt +8 -0
- data/lib/generators/authnz_eleven/templates/controllers/invitations_controller.rb.tt +65 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/api_tokens_controller.rb.tt +54 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/authentications/events_controller.rb.tt +5 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/dashboard_controller.rb.tt +4 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/email_verifications_controller.rb.tt +15 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/emails_controller.rb.tt +93 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/invitations_controller.rb.tt +61 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/authenticators_controller.rb.tt +87 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/recovery_codes_controller.rb.tt +27 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/security_keys_controller.rb.tt +86 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/sms_controller.rb.tt +84 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/omniauth_identities_controller.rb.tt +27 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/passkeys_controller.rb.tt +70 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/passwords_controller.rb.tt +74 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/phone_verifications_controller.rb.tt +39 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/phones_controller.rb.tt +88 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/sessions_controller.rb.tt +29 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings/users_controller.rb.tt +42 -0
- data/lib/generators/authnz_eleven/templates/controllers/settings_controller.rb.tt +5 -0
- data/lib/generators/authnz_eleven/templates/controllers/teams_controller.rb.tt +38 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments/passkeys_controller.rb.tt +36 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments/passwords_controller.rb.tt +28 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments_controller.rb.tt +14 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/easy_dev_logins_controller.rb.tt +19 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/email_verifications_controller.rb.tt +36 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/magic_link_controller.rb.tt +53 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/recovery_codes_controller.rb.tt +30 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/security_keys_controller.rb.tt +34 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/sms_controller.rb.tt +45 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/totps_controller.rb.tt +42 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/omniauth/registrations_controller.rb.tt +26 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/omniauth_controller.rb.tt +139 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/passkeys/sessions_controller.rb.tt +18 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/password_resets_controller.rb.tt +64 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/registrations/email_verifications_controller.rb.tt +53 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/registrations/phone_verifications_controller.rb.tt +37 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/registrations_controller.rb.tt +112 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/sessions_controller.rb.tt +52 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/sms_sessions_controller.rb.tt +53 -0
- data/lib/generators/authnz_eleven/templates/controllers/users/sudos_controller.rb.tt +42 -0
- data/lib/generators/authnz_eleven/templates/erb/admin/dashboard/index.html.erb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/erb/admin/sessions/index.html.erb.tt +27 -0
- data/lib/generators/authnz_eleven/templates/erb/admin/users/index.html.erb.tt +32 -0
- data/lib/generators/authnz_eleven/templates/erb/admin/users/show.html.erb.tt +70 -0
- data/lib/generators/authnz_eleven/templates/erb/invitations/show.html.erb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/api_tokens/create.html.erb.tt +7 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/api_tokens/index.html.erb.tt +29 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/authentications/events/index.html.erb.tt +18 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/dashboard/index.html.erb.tt +75 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/emails/edit.html.erb.tt +68 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/invitations/new.html.erb.tt +31 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/authenticators/new.html.erb.tt +57 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/recovery_codes/create.html.erb.tt +14 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/recovery_codes/index.html.erb.tt +13 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/edit.html.erb.tt +14 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/index.html.erb.tt +20 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/new.html.erb.tt +17 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/sms/new.html.erb.tt +35 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/omniauth_identities/index.html.erb.tt +28 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/edit.html.erb.tt +14 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/index.html.erb.tt +21 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/new.html.erb.tt +13 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/passwords/edit.html.erb.tt +68 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/phone_verifications/new.html.erb.tt +22 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/phones/edit.html.erb.tt +58 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/sessions/index.html.erb.tt +29 -0
- data/lib/generators/authnz_eleven/templates/erb/settings/users/show.html.erb.tt +28 -0
- data/lib/generators/authnz_eleven/templates/erb/shared/_authnz_eleven_nav.html.erb.tt +24 -0
- data/lib/generators/authnz_eleven/templates/erb/shared/_authnz_eleven_sudo_challenge.html.erb.tt +43 -0
- data/lib/generators/authnz_eleven/templates/erb/shared/_captcha.html.erb.tt +8 -0
- data/lib/generators/authnz_eleven/templates/erb/teams/index.html.erb.tt +34 -0
- data/lib/generators/authnz_eleven/templates/erb/teams/new.html.erb.tt +22 -0
- data/lib/generators/authnz_eleven/templates/erb/teams/show.html.erb.tt +5 -0
- data/lib/generators/authnz_eleven/templates/erb/user_mailer/email_change_verification.html.erb.tt +7 -0
- data/lib/generators/authnz_eleven/templates/erb/user_mailer/invitation.html.erb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/erb/user_mailer/magic_link.html.erb.tt +5 -0
- data/lib/generators/authnz_eleven/templates/erb/user_mailer/password_reset.html.erb.tt +6 -0
- data/lib/generators/authnz_eleven/templates/erb/user_mailer/registration_email_verification.html.erb.tt +7 -0
- data/lib/generators/authnz_eleven/templates/erb/user_mailer/security_notification.html.erb.tt +15 -0
- data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/passkeys/new.html.erb.tt +24 -0
- data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/passwords/new.html.erb.tt +21 -0
- data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/show.html.erb.tt +21 -0
- data/lib/generators/authnz_eleven/templates/erb/users/email_verifications/show.html.erb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/erb/users/magic_link/edit.html.erb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/erb/users/magic_link/new.html.erb.tt +22 -0
- data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/recovery_codes/new.html.erb.tt +20 -0
- data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/security_keys/new.html.erb.tt +17 -0
- data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/sms/new.html.erb.tt +25 -0
- data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/totps/new.html.erb.tt +21 -0
- data/lib/generators/authnz_eleven/templates/erb/users/omniauth/registrations/new.html.erb.tt +28 -0
- data/lib/generators/authnz_eleven/templates/erb/users/password_resets/edit.html.erb.tt +26 -0
- data/lib/generators/authnz_eleven/templates/erb/users/password_resets/new.html.erb.tt +20 -0
- data/lib/generators/authnz_eleven/templates/erb/users/registrations/email_verifications/new.html.erb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/erb/users/registrations/email_verifications/show.html.erb.tt +34 -0
- data/lib/generators/authnz_eleven/templates/erb/users/registrations/new.html.erb.tt +87 -0
- data/lib/generators/authnz_eleven/templates/erb/users/registrations/phone_verifications/new.html.erb.tt +46 -0
- data/lib/generators/authnz_eleven/templates/erb/users/sessions/new.html.erb.tt +88 -0
- data/lib/generators/authnz_eleven/templates/erb/users/sms_sessions/edit.html.erb.tt +30 -0
- data/lib/generators/authnz_eleven/templates/erb/users/sms_sessions/new.html.erb.tt +22 -0
- data/lib/generators/authnz_eleven/templates/erb/users/sudos/new.html.erb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/javascript/controllers/webauthn_controller.js.tt +142 -0
- data/lib/generators/authnz_eleven/templates/jobs/authnz_eleven_deferred_job.rb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/jobs/authnz_eleven_mail_delivery_job.rb.tt +3 -0
- data/lib/generators/authnz_eleven/templates/jobs/sms_delivery_job.rb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/lib/captcha/null.rb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/lib/captcha/turnstile.rb.tt +39 -0
- data/lib/generators/authnz_eleven/templates/lib/captcha.rb.tt +15 -0
- data/lib/generators/authnz_eleven/templates/lib/sms/log.rb.tt +8 -0
- data/lib/generators/authnz_eleven/templates/lib/sms/twilio.rb.tt +70 -0
- data/lib/generators/authnz_eleven/templates/lib/sms.rb.tt +15 -0
- data/lib/generators/authnz_eleven/templates/mailers/user_mailer.rb.tt +98 -0
- data/lib/generators/authnz_eleven/templates/migrations/add_guest_to_users_migration.rb.tt +6 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_api_tokens_migration.rb.tt +14 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_events_migration.rb.tt +26 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_invitations_migration.rb.tt +20 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_memberships_migration.rb.tt +12 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_omniauth_identities_migration.rb.tt +16 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_password_histories_migration.rb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_pending_registrations_migration.rb.tt +31 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_recovery_codes_migration.rb.tt +11 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_sessions_migration.rb.tt +19 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_teams_migration.rb.tt +9 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_users_migration.rb.tt +70 -0
- data/lib/generators/authnz_eleven/templates/migrations/create_webauthn_credentials_migration.rb.tt +16 -0
- data/lib/generators/authnz_eleven/templates/models/api_token.rb.tt +24 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/adminable.rb.tt +15 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/authenticatable.rb.tt +151 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/bannable.rb.tt +24 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/deadboltable.rb.tt +39 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/email_verifiable.rb.tt +52 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/eventable.rb.tt +22 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/guestable.rb.tt +30 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/invitable.rb.tt +12 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/magic_linkable.rb.tt +34 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/password_historical.rb.tt +28 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/password_policy.rb.tt +38 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/password_rotatable.rb.tt +15 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/phone_verifiable.rb.tt +50 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/principals.rb.tt +67 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/recoverable.rb.tt +19 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/second_factor.rb.tt +52 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/sms_challengeable.rb.tt +38 -0
- data/lib/generators/authnz_eleven/templates/models/concerns/team_member.rb.tt +20 -0
- data/lib/generators/authnz_eleven/templates/models/current.rb.tt +28 -0
- data/lib/generators/authnz_eleven/templates/models/event.rb.tt +32 -0
- data/lib/generators/authnz_eleven/templates/models/invitation.rb.tt +122 -0
- data/lib/generators/authnz_eleven/templates/models/membership.rb.tt +16 -0
- data/lib/generators/authnz_eleven/templates/models/omniauth_identity.rb.tt +12 -0
- data/lib/generators/authnz_eleven/templates/models/password_history.rb.tt +3 -0
- data/lib/generators/authnz_eleven/templates/models/pending_registration.rb.tt +216 -0
- data/lib/generators/authnz_eleven/templates/models/recovery_code.rb.tt +41 -0
- data/lib/generators/authnz_eleven/templates/models/session.rb.tt +46 -0
- data/lib/generators/authnz_eleven/templates/models/team.rb.tt +12 -0
- data/lib/generators/authnz_eleven/templates/models/user.rb.tt +59 -0
- data/lib/generators/authnz_eleven/templates/models/webauthn_credential.rb.tt +12 -0
- data/lib/generators/authnz_eleven/templates/tasks/authnz_eleven.rake.tt +41 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/account_existence_parity_test.rb.tt +158 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/admin/access_test.rb.tt +139 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/admin/multi_factor_authentication_test.rb.tt +65 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/api_token_authentication_test.rb.tt +45 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/credential_enrollment_test.rb.tt +84 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/guest_access_test.rb.tt +113 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/invitations_controller_test.rb.tt +230 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/passkey_ceremony_test.rb.tt +126 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/password_rotation_test.rb.tt +67 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/security_key_ceremony_test.rb.tt +218 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/security_notifications_test.rb.tt +39 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/session_lifecycle_test.rb.tt +102 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/api_tokens_controller_test.rb.tt +61 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/authentications/events_controller_test.rb.tt +17 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/email_verifications_controller_test.rb.tt +39 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/invitations_controller_test.rb.tt +157 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/management_test.rb.tt +256 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/multi_factor_authentication/management_test.rb.tt +222 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/omniauth_identities_controller_test.rb.tt +57 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/phone_verifications_controller_test.rb.tt +146 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/teams_controller_test.rb.tt +83 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/email_verifications_controller_test.rb.tt +74 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/magic_link_controller_test.rb.tt +90 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/multi_factor_authentication/challenge_test.rb.tt +143 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/omniauth/registrations_controller_test.rb.tt +138 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/omniauth_controller_test.rb.tt +97 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/password_resets_controller_test.rb.tt +58 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations/email_verifications_controller_test.rb.tt +102 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations/phone_verifications_controller_test.rb.tt +69 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations_controller_test.rb.tt +180 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sessions_controller_test.rb.tt +124 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sms_sessions_controller_test.rb.tt +111 -0
- data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sudos_controller_test.rb.tt +110 -0
- data/lib/generators/authnz_eleven/templates/test_unit/fixtures/memberships.yml.tt +13 -0
- data/lib/generators/authnz_eleven/templates/test_unit/fixtures/teams.yml.tt +2 -0
- data/lib/generators/authnz_eleven/templates/test_unit/fixtures/users.yml.tt +68 -0
- data/lib/generators/authnz_eleven/templates/test_unit/mailers/user_mailer_test.rb.tt +88 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/api_token_test.rb.tt +38 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/password_history_test.rb.tt +51 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/session_test.rb.tt +16 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/sms_challengeable_test.rb.tt +108 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/user/guestable_test.rb.tt +78 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/user/recovery_codes_test.rb.tt +32 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/user/sign_in_methods_test.rb.tt +56 -0
- data/lib/generators/authnz_eleven/templates/test_unit/models/user_test.rb.tt +145 -0
- data/lib/generators/authnz_eleven/wizard_generator.rb +52 -0
- metadata +309 -0
|
@@ -0,0 +1,3040 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require "active_support/core_ext/string/filters"
|
|
6
|
+
require_relative "../../authnz_eleven/version"
|
|
7
|
+
require_relative "../../authnz_eleven/identity"
|
|
8
|
+
require_relative "../../authnz_eleven/principals"
|
|
9
|
+
|
|
10
|
+
# Generates a pre-built authentication system into a Rails application.
|
|
11
|
+
#
|
|
12
|
+
# The core is DB-backed sessions, channel verification and /settings. Doors —
|
|
13
|
+
# --password, --magic-link, --sms-code, --omniauth, --passkey — and everything
|
|
14
|
+
# else is selected through flags; at least one principal and one door are required.
|
|
15
|
+
#
|
|
16
|
+
# Behavior is split into concerns rather than a fat User, and tunables live in
|
|
17
|
+
# the generated initializer rather than in flags.
|
|
18
|
+
class AuthnzElevenGenerator < Rails::Generators::Base
|
|
19
|
+
include ActiveRecord::Generators::Migration
|
|
20
|
+
|
|
21
|
+
# With this many flags, a misspelling is the likeliest way to get a build that is
|
|
22
|
+
# quietly missing a feature: Thor's default is to ignore what it doesn't
|
|
23
|
+
# recognise, so `--password-rotateable` would generate an app with no rotation and say
|
|
24
|
+
# nothing. Refuse instead.
|
|
25
|
+
check_unknown_options!
|
|
26
|
+
|
|
27
|
+
class_option :email, type: :string, lazy_default: "required", group: "Identifier", desc: "Sign in with an email address: required (default) or optional. Add ,permanent to stop users changing it"
|
|
28
|
+
class_option :phone, type: :string, lazy_default: "required", group: "Identifier", desc: "Sign in with a phone number: required (default) or optional. Add ,permanent to stop users changing it"
|
|
29
|
+
class_option :username, type: :boolean, group: "Identifier", desc: "Sign in with a unique, unchangeable username"
|
|
30
|
+
class_option :registration, type: :string, default: "open", enum: %w[open open-and-invites invite-only closed], group: "Identifier", desc: "Who can create an account"
|
|
31
|
+
class_option :contactable, type: :boolean, default: true, group: "Identifier", desc: "Every account must have an email or phone, so you can reach them"
|
|
32
|
+
class_option :verifiable, type: :boolean, default: true, group: "Identifier", desc: "Verify emails by link and phones by texted code"
|
|
33
|
+
class_option :coy, type: :boolean, default: false, group: "Identifier", desc: "Respond the same whether or not an account exists for an email or phone"
|
|
34
|
+
class_option :encrypted_pii, type: :boolean, group: "Identifier", desc: "Encrypt emails and phone numbers at rest"
|
|
35
|
+
class_option :guestable, type: :boolean, group: "Identifier", desc: "Anonymous guest users, absorbed into the account when they sign up"
|
|
36
|
+
|
|
37
|
+
class_option :password, type: :string, lazy_default: "required", enum: %w[required optional deferred], group: "Sign-in", desc: "Password sign-in. deferred asks for it after verification"
|
|
38
|
+
class_option :magic_link, type: :boolean, group: "Sign-in", desc: "Sign in by emailed link"
|
|
39
|
+
class_option :sms_code, type: :boolean, group: "Sign-in", desc: "Sign in by texted six-digit code. Needs --phone"
|
|
40
|
+
class_option :passkey, type: :boolean, group: "Sign-in", desc: "Sign in with a passkey"
|
|
41
|
+
class_option :omniauth, type: :boolean, group: "Sign-in", desc: "Social / SSO login via OmniAuth"
|
|
42
|
+
|
|
43
|
+
class_option :recoverable, type: :boolean, group: "Password", desc: "Password reset by emailed link"
|
|
44
|
+
class_option :pwned, type: :boolean, group: "Password", desc: "Reject passwords found in known data breaches"
|
|
45
|
+
class_option :strong_passwords, type: :boolean, group: "Password", desc: "Reject weak passwords by zxcvbn score"
|
|
46
|
+
class_option :deadboltable, type: :boolean, group: "Password", desc: "Shut password sign-in for a while after too many failures. Needs --password"
|
|
47
|
+
class_option :password_rotatable, type: :boolean, group: "Password", desc: "Make users change their password every 90 days"
|
|
48
|
+
class_option :password_historical, type: :boolean, group: "Password", desc: "Reject reuse of the last 5 passwords"
|
|
49
|
+
|
|
50
|
+
class_option :rememberable, type: :boolean, group: "Session", desc: "A 'remember me' checkbox on password sign-in"
|
|
51
|
+
class_option :timeoutable, type: :boolean, group: "Session", desc: "Sign users out after a period of inactivity"
|
|
52
|
+
class_option :max_sessionable, type: :string, lazy_default: "evict", enum: %w[evict prompt], group: "Session", desc: "Cap concurrent sessions: evict the oldest (default), or prompt the user to end one"
|
|
53
|
+
class_option :last_seenable, type: :boolean, group: "Session", desc: "Record when each user was last seen"
|
|
54
|
+
class_option :trackable, type: :boolean, group: "Session", desc: "An audit trail of authentication activity"
|
|
55
|
+
|
|
56
|
+
class_option :api_tokens, type: :boolean, group: "API", desc: "Personal access tokens for your own API endpoints"
|
|
57
|
+
|
|
58
|
+
class_option :second_factor, type: :string, lazy_default: "totp", group: "Hardening", desc: "Two-factor auth: totp (default), webauthn, sms, or a comma-separated mix"
|
|
59
|
+
class_option :sudoable, type: :boolean, group: "Hardening", desc: "Ask users to authenticate again before dangerous actions"
|
|
60
|
+
class_option :captchable, type: :boolean, group: "Hardening", desc: "A Cloudflare Turnstile captcha on the signed-out forms"
|
|
61
|
+
class_option :security_notifications, type: :boolean, group: "Hardening", desc: "Email users when security settings change. Needs --email"
|
|
62
|
+
|
|
63
|
+
class_option :adminable, type: :boolean, group: "Admin", desc: "Users can be site admins, set in the database"
|
|
64
|
+
class_option :admin_dashboard, type: :boolean, group: "Admin", desc: "An /admin area for users and sessions"
|
|
65
|
+
class_option :bannable, type: :boolean, group: "Admin", desc: "Admins can ban users, temporarily or permanently"
|
|
66
|
+
class_option :impersonatable, type: :boolean, group: "Admin", desc: "Admins can impersonate other users"
|
|
67
|
+
class_option :easy_dev_login, type: :boolean, group: "Admin", desc: "Development only: sign in as anyone"
|
|
68
|
+
|
|
69
|
+
class_option :teams, type: :string, lazy_default: "scope", enum: %w[scope middleware session], group: "Team", desc: "Teams and memberships. The current team lives in the URL (scope, middleware) or the session"
|
|
70
|
+
|
|
71
|
+
class_option :user_class, type: :string, group: "Identity class", desc: "Name of the identity class (default User)"
|
|
72
|
+
class_option :namespaced, type: :boolean, group: "Identity class", desc: "Its own URL prefix and namespace. Required for a second identity"
|
|
73
|
+
class_option :primary_key_type, type: :string, group: "Identity class", desc: "Primary key type for the migrations, e.g. uuid"
|
|
74
|
+
|
|
75
|
+
source_root File.expand_path("templates", __dir__)
|
|
76
|
+
|
|
77
|
+
def validate_principal!
|
|
78
|
+
return if principals.any?
|
|
79
|
+
|
|
80
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
81
|
+
No identifier was selected. Add --email, --phone or --username.
|
|
82
|
+
MSG
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Fail fast when a generated app would have no way to sign in.
|
|
86
|
+
def validate_authentication_strategy!
|
|
87
|
+
return if password? || magic_link? || omniauth? || passkey? || sms_code?
|
|
88
|
+
|
|
89
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
90
|
+
No sign-in method was selected. Add --password, --magic-link, --sms-code,
|
|
91
|
+
--passkey or --omniauth.
|
|
92
|
+
MSG
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --second-factor names the factors an account may enroll, so an unknown one is a
|
|
96
|
+
# typo that would otherwise generate a build quietly missing the factor asked for.
|
|
97
|
+
def validate_second_factor!
|
|
98
|
+
unknown = second_factors - %i[totp webauthn sms]
|
|
99
|
+
|
|
100
|
+
unless unknown.empty?
|
|
101
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
102
|
+
Unknown second factor#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")}.
|
|
103
|
+
Use totp, webauthn, sms, or a comma-separated mix like totp,webauthn.
|
|
104
|
+
For passkey sign-in, use --passkey.
|
|
105
|
+
MSG
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return unless sms_second_factor?
|
|
109
|
+
|
|
110
|
+
if principals.phone.nil?
|
|
111
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
112
|
+
--second-factor=sms needs --phone.
|
|
113
|
+
MSG
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return unless sms_code?
|
|
117
|
+
|
|
118
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
119
|
+
--sms-code and --second-factor=sms both text a code to the same phone, so
|
|
120
|
+
the second text adds no security. Use one or the other.
|
|
121
|
+
MSG
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A deadbolt counts failed guesses at a password, so without one there is nothing
|
|
125
|
+
# to count and nothing to bar. Refused rather than ignored: silently generating
|
|
126
|
+
# no deadbolt for a build that asked for one is how you end up believing you have a
|
|
127
|
+
# brute-force defence that was never emitted.
|
|
128
|
+
def validate_deadboltable!
|
|
129
|
+
return unless options.deadboltable?
|
|
130
|
+
return if password?
|
|
131
|
+
|
|
132
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
133
|
+
--deadboltable needs --password. The other sign-in methods can't be
|
|
134
|
+
guessed. To block an account entirely, use --bannable.
|
|
135
|
+
MSG
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_coy!
|
|
139
|
+
return unless coy? && !options.verifiable?
|
|
140
|
+
|
|
141
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
142
|
+
--coy needs verification. Without it, sign-up says when an email or phone
|
|
143
|
+
number is already taken. Remove --no-verifiable, or remove --coy.
|
|
144
|
+
MSG
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_sudoable!
|
|
148
|
+
return unless sudoable?
|
|
149
|
+
return if sudo_bars.any?
|
|
150
|
+
|
|
151
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
152
|
+
--sudoable needs a password, a passkey, or a totp or webauthn second
|
|
153
|
+
factor to ask for. Add --password, --passkey or --second-factor.
|
|
154
|
+
MSG
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_security_notifications!
|
|
158
|
+
return unless security_notifications? && principals.email.nil?
|
|
159
|
+
|
|
160
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
161
|
+
--security-notifications needs --email so there is somewhere to send them.
|
|
162
|
+
MSG
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --password=optional means "optional because another door covers you" — the
|
|
166
|
+
# sense --contactable gives --email=optional. With no other door there is
|
|
167
|
+
# nothing to be optional against, and a blank field would mint an account that
|
|
168
|
+
# can never sign in. Same shape as validate_email_none!, and the same reason
|
|
169
|
+
# --contactable refuses a lone optional channel.
|
|
170
|
+
def validate_password_optional!
|
|
171
|
+
return unless password_form_optional?
|
|
172
|
+
return if non_password_doors?
|
|
173
|
+
|
|
174
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
175
|
+
--password=optional needs another sign-in method, or users who skip the
|
|
176
|
+
password can't sign in. Add --passkey, --omniauth, --magic-link or
|
|
177
|
+
--sms-code, or use --password.
|
|
178
|
+
MSG
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --password=deferred moves the password off the sign-up form onto a second
|
|
182
|
+
# page. That page must earn its place for every sign-up: it either follows a
|
|
183
|
+
# channel verification, or offers a choice between a password and a passkey.
|
|
184
|
+
# Otherwise it is one page's work split across two.
|
|
185
|
+
def validate_password_deferred!
|
|
186
|
+
return unless password_deferred?
|
|
187
|
+
return if passkey?
|
|
188
|
+
return if contactable? && (email_verifiable? || phone_verifiable?)
|
|
189
|
+
|
|
190
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
191
|
+
--password=deferred asks for the password after the user verifies their
|
|
192
|
+
email or phone, and with these flags not every sign-up does. Add --passkey,
|
|
193
|
+
or use --password or --password=optional.
|
|
194
|
+
MSG
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --sms-code texts the code to the number on the account.
|
|
198
|
+
def validate_sms_code_option!
|
|
199
|
+
return unless options.sms_code?
|
|
200
|
+
|
|
201
|
+
if principals.phone.nil?
|
|
202
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
203
|
+
--sms-code needs --phone.
|
|
204
|
+
MSG
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
return unless principals.phone.optional?
|
|
208
|
+
return if password? || magic_link? || omniauth? || passkey?
|
|
209
|
+
|
|
210
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
211
|
+
--sms-code is the only sign-in method, so every account needs a phone
|
|
212
|
+
number. Use --phone, or add another sign-in method.
|
|
213
|
+
MSG
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --email takes a requiredness word, optionally followed by modifiers.
|
|
217
|
+
def validate_email_option!
|
|
218
|
+
return unless options[:email]
|
|
219
|
+
|
|
220
|
+
return if principal_mode_valid?(options[:email])
|
|
221
|
+
|
|
222
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
223
|
+
--email=#{options[:email]} isn't valid. Use --email, --email=optional or
|
|
224
|
+
--email=required,permanent.
|
|
225
|
+
MSG
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# --phone mirrors --email's value guard. Omission means no phone column.
|
|
229
|
+
def validate_phone_option!
|
|
230
|
+
return unless options[:phone]
|
|
231
|
+
|
|
232
|
+
return if principal_mode_valid?(options[:phone])
|
|
233
|
+
|
|
234
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
235
|
+
--phone=#{options[:phone]} isn't valid. Use --phone, --phone=optional or
|
|
236
|
+
--phone=required,permanent.
|
|
237
|
+
MSG
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# "permanent" removes the settings surface for a principal, so the value a row
|
|
241
|
+
# is created with is the one it keeps. That only holds when the value is there
|
|
242
|
+
# from the start. An optional one may arrive later, which needs the settings form.
|
|
243
|
+
def validate_permanent!
|
|
244
|
+
principal = principals.select(&:permanent?).find(&:optional?)
|
|
245
|
+
return unless principal
|
|
246
|
+
|
|
247
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
248
|
+
--#{principal.column}=optional,permanent isn't supported. A permanent value
|
|
249
|
+
has to be given at sign-up. Use --#{principal.column}=required,permanent.
|
|
250
|
+
MSG
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# --no-verifiable leaves nobody having proved the address, so permanent freezes
|
|
254
|
+
# whatever was typed, typo included: no reset, no magic link, no settings form.
|
|
255
|
+
# Warn rather than refuse — the column stays writable, so an admin or a console
|
|
256
|
+
# can still fix it, and that is the host's call to make.
|
|
257
|
+
def warn_about_unverified_permanent!
|
|
258
|
+
permanent = principals.select { |p| p.permanent? && !channel_verifiable?(p) }
|
|
259
|
+
return if permanent.empty?
|
|
260
|
+
|
|
261
|
+
say_status :warning, <<~MSG.squish, :yellow
|
|
262
|
+
--#{permanent.map(&:column).join(" and --")}=required,permanent with --no-verifiable:
|
|
263
|
+
the value is never verified, so users can't fix a typo from sign-up. Only an
|
|
264
|
+
admin can.
|
|
265
|
+
MSG
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def validate_channel_dependencies!
|
|
269
|
+
conflicts = []
|
|
270
|
+
conflicts << "--recoverable needs --email." if recoverable? && principals.email.nil?
|
|
271
|
+
conflicts << "--magic-link needs --email." if magic_link? && principals.email.nil?
|
|
272
|
+
conflicts << "--registration=#{options[:registration]} needs --email or --phone." if invitable? && principals.channels.empty?
|
|
273
|
+
return if conflicts.empty?
|
|
274
|
+
|
|
275
|
+
raise Rails::Generators::Error, conflicts.join(" ")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# --contactable promises every account holds a channel. With TWO optional channels
|
|
279
|
+
# that promise is a real record-level rule (Principals#must_be_contactable): give a
|
|
280
|
+
# number or an address, either will do. With only ONE channel there is nothing for
|
|
281
|
+
# "optional" to be optional against, and the promise and the flag disagree.
|
|
282
|
+
#
|
|
283
|
+
# Refuse rather than resolve it. Promoting the column to required would override
|
|
284
|
+
# what the author wrote without saying so; leaving it alone would let the flag be
|
|
285
|
+
# on while accounts hold no channel at all. Making them say which they meant is
|
|
286
|
+
# the only option honest in both directions.
|
|
287
|
+
def validate_contactable!
|
|
288
|
+
return unless contactable?
|
|
289
|
+
|
|
290
|
+
channels = principals.select(&:channel?)
|
|
291
|
+
return if channels.size > 1 || channels.any?(&:required?)
|
|
292
|
+
return if channels.empty? # A username-only build has no channel to require.
|
|
293
|
+
|
|
294
|
+
only = channels.sole
|
|
295
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
296
|
+
With --#{only.column}=optional and no #{only.type == :email ? "phone" : "email"},
|
|
297
|
+
an account could have no way to be contacted. Use --#{only.column},
|
|
298
|
+
add --#{only.type == :email ? "phone=optional" : "email=optional"}, or allow it
|
|
299
|
+
with --no-contactable.
|
|
300
|
+
MSG
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A generated identity claims a top-level Ruby constant for its model (e.g.
|
|
304
|
+
# --user-class=Admin -> class Admin, app/models/admin.rb). If that constant is
|
|
305
|
+
# already a namespace on disk (e.g. app/controllers/admin/ from --admin-dashboard's
|
|
306
|
+
# Admin::* controllers), Zeitwerk raises at boot — far from where the mistake
|
|
307
|
+
# was made. Catch it here instead. Checks what is actually on disk rather than
|
|
308
|
+
# hardcoding "admin" as a reserved word, so it also catches collisions with any
|
|
309
|
+
# other existing app/controllers namespace.
|
|
310
|
+
def validate_no_constant_collision!
|
|
311
|
+
if admin_dashboard? && identity.singular == "admin" && !identity.namespaced?
|
|
312
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
313
|
+
--admin-dashboard and --user-class=Admin can't be combined: both define
|
|
314
|
+
Admin. Choose a different --user-class.
|
|
315
|
+
MSG
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
namespace_dir = in_destination("app/controllers/#{identity.singular}")
|
|
319
|
+
# An empty (or nonexistent) directory defines no autoload entry, so it can't
|
|
320
|
+
# actually collide — only files inside it would. Check for those, not just
|
|
321
|
+
# the directory's existence, to avoid flagging harmless leftover/stray dirs.
|
|
322
|
+
return if Dir.glob("#{namespace_dir}/**/*.rb").none?
|
|
323
|
+
|
|
324
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
325
|
+
--user-class=#{identity.class_name} can't be used: app/controllers/#{identity.singular}/
|
|
326
|
+
already defines a #{identity.class_name} module, which would clash with the
|
|
327
|
+
#{identity.class_name} model. Choose a different --user-class.
|
|
328
|
+
MSG
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A second (or later) identity must be namespaced — a run without --namespaced
|
|
332
|
+
# always writes to the same shared paths (app/models/session.rb, app/models/current.rb,
|
|
333
|
+
# app/controllers/application_controller.rb, ...) regardless of --user-class, so
|
|
334
|
+
# a second such run collides with the incumbent identity. Catch it up front
|
|
335
|
+
# with a clear message instead of Rails' per-file overwrite prompt.
|
|
336
|
+
def validate_second_identity_namespaced!
|
|
337
|
+
return if identity.namespaced?
|
|
338
|
+
return unless File.exist?(in_destination("app/models/session.rb"))
|
|
339
|
+
return if File.read(in_destination("app/models/session.rb")).include?("belongs_to :#{identity.singular}")
|
|
340
|
+
|
|
341
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
342
|
+
This app already has an identity, so a second one must be generated
|
|
343
|
+
with --namespaced. Re-run with --user-class=#{identity.class_name} --namespaced.
|
|
344
|
+
MSG
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def validate_middleware_teams!
|
|
348
|
+
return unless middleware_teams? && identity.namespaced?
|
|
349
|
+
|
|
350
|
+
raise Rails::Generators::Error, <<~MSG.squish
|
|
351
|
+
--teams=middleware can't be used with --namespaced. Use --teams or
|
|
352
|
+
--teams=session.
|
|
353
|
+
MSG
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def add_gems
|
|
357
|
+
if password?
|
|
358
|
+
if bcrypt_present?
|
|
359
|
+
uncomment_lines "Gemfile", /gem "bcrypt"/
|
|
360
|
+
else
|
|
361
|
+
gem "bcrypt", "~> 3.1.7", comment: "Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
if principals.email
|
|
366
|
+
gem "valid_email2", comment: "Use valid_email2 for well-tested email syntax validation [https://github.com/micke/valid_email2]"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
if phone?
|
|
370
|
+
gem "phonelib", comment: "Use phonelib to parse, validate, and normalize phone numbers to E.164 [https://github.com/daddyz/phonelib]"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
if omniauth?
|
|
374
|
+
gem "omniauth", ">= 2.0", comment: "Use OmniAuth to support multi-provider authentication [https://github.com/omniauth/omniauth]"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
if second_factor?
|
|
378
|
+
gem "rotp", comment: "Use rotp for generating and validating one time passwords [https://github.com/mdp/rotp]"
|
|
379
|
+
gem "rqrcode", comment: "Use rqrcode for creating and rendering QR codes into various formats [https://github.com/whomwah/rqrcode]"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
if webauthn_credentials?
|
|
383
|
+
gem "webauthn", comment: "Use webauthn to make Rails a conformant WebAuthn relying party [https://github.com/cedarcode/webauthn-ruby]"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
if pwned?
|
|
387
|
+
gem "pwned", comment: "Use Pwned to check if a password has been found in any data breach [https://github.com/philnash/pwned]"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if strong_passwords?
|
|
391
|
+
gem "zxcvbn", comment: "Use zxcvbn to score password strength and reject weak passwords [https://github.com/envato/zxcvbn-ruby]"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def create_configuration_files
|
|
396
|
+
template "config/initializers/authentication.rb.tt", "config/initializers/#{identity.initializer_file}.rb"
|
|
397
|
+
template "config/locales/authentication.en.yml.tt", "config/locales/#{identity.initializer_file}.en.yml"
|
|
398
|
+
install_omniauth_initializer
|
|
399
|
+
template "config/initializers/webauthn.rb.tt", "config/initializers/webauthn.rb" if webauthn_credentials? && !File.exist?(in_destination("config/initializers/webauthn.rb"))
|
|
400
|
+
template "config/initializers/team_slug.rb.tt", "config/initializers/team_slug.rb" if middleware_teams?
|
|
401
|
+
|
|
402
|
+
if principals.username
|
|
403
|
+
# The forbidden-username blocklist is plain data read by the user model's
|
|
404
|
+
# validation, and it's identity-free — a second --username identity reuses
|
|
405
|
+
# the first writer's copy (first-writer-wins, like the captcha lib).
|
|
406
|
+
copy_file "config/forbidden_usernames.txt", "config/forbidden_usernames.txt" unless File.exist?(in_destination("config/forbidden_usernames.txt"))
|
|
407
|
+
copy_file "config/forbidden_usernames.LICENSE", "config/forbidden_usernames.LICENSE" unless File.exist?(in_destination("config/forbidden_usernames.LICENSE"))
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Rails' stock filter list doesn't reach the columns this generator adds, so
|
|
412
|
+
# they log in the clear — and since ActiveRecord::Base.filter_attributes reads
|
|
413
|
+
# the same list, they print from `inspect` too.
|
|
414
|
+
#
|
|
415
|
+
# They go in config/initializers/filter_parameter_logging.rb rather than our own
|
|
416
|
+
# initializer: that file is where someone auditing "what does this app redact?"
|
|
417
|
+
# looks. It belongs to the host app, so this only appends, and only what isn't
|
|
418
|
+
# already there (a re-run, or a second identity sharing a filter with the first).
|
|
419
|
+
def install_parameter_filters
|
|
420
|
+
names = parameter_filters
|
|
421
|
+
initializer = "config/initializers/filter_parameter_logging.rb"
|
|
422
|
+
return if names.empty?
|
|
423
|
+
|
|
424
|
+
unless File.exist?(in_destination(initializer))
|
|
425
|
+
@parameter_filter_line = filter_parameters_line(names)
|
|
426
|
+
return
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
contents = File.read(in_destination(initializer))
|
|
430
|
+
names = names.reject { |name| contents.include?(filter_regexp(name)) }
|
|
431
|
+
return if names.empty?
|
|
432
|
+
|
|
433
|
+
append_to_file initializer, <<~RUBY
|
|
434
|
+
|
|
435
|
+
# Added by authnz_eleven: identifiers and secrets this authentication system
|
|
436
|
+
# stores or receives that the list above doesn't reach. Anchored rather than
|
|
437
|
+
# substring-matched (the notation the list above uses) so that, for example,
|
|
438
|
+
# `uid` doesn't also swallow `uuid`, and `code` leaves unrelated names that
|
|
439
|
+
# merely contain the same substring readable.
|
|
440
|
+
#{filter_parameters_line(names)}
|
|
441
|
+
RUBY
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# One builder per identity, in its own file: a strategy's path_prefix must match
|
|
445
|
+
# the prefix its callbacks are routed under, and each identity picks its providers.
|
|
446
|
+
def install_omniauth_initializer
|
|
447
|
+
return unless omniauth?
|
|
448
|
+
|
|
449
|
+
template "config/initializers/omniauth.rb.tt", "config/initializers/#{identity.namespaced? ? "#{identity.singular}_omniauth" : "omniauth"}.rb"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def omniauth_path_prefix
|
|
453
|
+
identity.namespaced? ? "/#{identity.path_prefix}/auth" : "/auth"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Rails ships :null_store in test. Texted codes and magic links live in the cache
|
|
457
|
+
# and fail closed on a miss, so a build with either needs a real store for its
|
|
458
|
+
# own suite to pass. Throttles fail open and aren't listed.
|
|
459
|
+
def configure_test_cache
|
|
460
|
+
environment = "config/environments/test.rb"
|
|
461
|
+
return unless sms? || magic_link?
|
|
462
|
+
return unless File.read(in_destination(environment)).include?("config.cache_store = :null_store")
|
|
463
|
+
|
|
464
|
+
gsub_file environment, "config.cache_store = :null_store", "config.cache_store = :memory_store"
|
|
465
|
+
|
|
466
|
+
test_helper = "test/test_helper.rb"
|
|
467
|
+
reset = "ActiveSupport::TestCase.setup { Rails.cache.clear }"
|
|
468
|
+
return unless File.exist?(in_destination(test_helper)) && !File.read(in_destination(test_helper)).include?(reset)
|
|
469
|
+
|
|
470
|
+
append_to_file test_helper, "\n#{reset}\n"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Behaviour tests see deferred work done by the time the request returns. The
|
|
474
|
+
# emitted account-existence parity test swaps the adapter back to prove it left.
|
|
475
|
+
def configure_test_deferred_jobs
|
|
476
|
+
test_helper = "test/test_helper.rb"
|
|
477
|
+
inline = "AuthnzElevenDeferredJob.queue_adapter = :inline"
|
|
478
|
+
return unless coy? && File.exist?(in_destination(test_helper))
|
|
479
|
+
return if File.read(in_destination(test_helper)).include?(inline)
|
|
480
|
+
|
|
481
|
+
append_to_file test_helper, "\n#{inline}\n"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Tests answer a WebAuthn sudo bar by tapping whichever fake key @client holds.
|
|
485
|
+
def configure_test_webauthn
|
|
486
|
+
test_helper = "test/test_helper.rb"
|
|
487
|
+
return unless webauthn_credentials? && File.exist?(in_destination(test_helper))
|
|
488
|
+
|
|
489
|
+
require_line = %(require "webauthn/fake_client")
|
|
490
|
+
append_to_file test_helper, "\n#{require_line}\n" unless File.read(in_destination(test_helper)).include?(require_line)
|
|
491
|
+
assertion = "#{identity.route_scope}_sudo_assertion"
|
|
492
|
+
return if !sudoable? || File.read(in_destination(test_helper)).include?("def #{assertion}")
|
|
493
|
+
|
|
494
|
+
append_to_file test_helper, <<~RUBY
|
|
495
|
+
|
|
496
|
+
class ActionDispatch::IntegrationTest
|
|
497
|
+
def #{assertion}
|
|
498
|
+
get new_#{identity.route_scope}_sudo_path(format: :json)
|
|
499
|
+
@client.get(challenge: response.parsed_body.fetch("challenge"), rp_id: WebAuthn.configuration.rp_id,
|
|
500
|
+
user_verified: true).to_json
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
RUBY
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Fixtures are written in the clear, so without this the suite stores plaintext into
|
|
507
|
+
# an encrypted column and every read of it raises. Rails defaults it off, and the
|
|
508
|
+
# file belongs to the host app, so only append when it isn't already set.
|
|
509
|
+
def configure_fixture_encryption
|
|
510
|
+
environment = "config/environments/test.rb"
|
|
511
|
+
setting = "config.active_record.encryption.encrypt_fixtures = true"
|
|
512
|
+
return unless encryption? && File.exist?(in_destination(environment))
|
|
513
|
+
return if File.read(in_destination(environment)).include?(setting)
|
|
514
|
+
|
|
515
|
+
inject_into_file environment, "\n #{setting}\n", before: /^end\b/
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def create_migrations
|
|
519
|
+
migration_template "migrations/create_users_migration.rb.tt", "#{db_migrate_path}/create_#{identity.table}.rb"
|
|
520
|
+
migration_template "migrations/create_teams_migration.rb.tt", "#{db_migrate_path}/create_#{identity.teams_table}.rb" if teams?
|
|
521
|
+
migration_template "migrations/create_memberships_migration.rb.tt", "#{db_migrate_path}/create_#{identity.memberships_table}.rb" if teams?
|
|
522
|
+
migration_template "migrations/create_sessions_migration.rb.tt", "#{db_migrate_path}/create_#{identity.sessions_table}.rb"
|
|
523
|
+
migration_template "migrations/create_api_tokens_migration.rb.tt", "#{db_migrate_path}/create_#{identity.api_tokens_table}.rb" if api_tokens?
|
|
524
|
+
migration_template "migrations/create_events_migration.rb.tt", "#{db_migrate_path}/create_#{identity.events_table}.rb" if trackable?
|
|
525
|
+
migration_template "migrations/create_recovery_codes_migration.rb.tt", "#{db_migrate_path}/create_#{identity.recovery_codes_table}.rb" if second_factor?
|
|
526
|
+
migration_template "migrations/create_webauthn_credentials_migration.rb.tt", "#{db_migrate_path}/create_#{identity.webauthn_credentials_table}.rb" if webauthn_credentials?
|
|
527
|
+
migration_template "migrations/create_password_histories_migration.rb.tt", "#{db_migrate_path}/create_#{identity.password_histories_table}.rb" if password_historical?
|
|
528
|
+
migration_template "migrations/create_invitations_migration.rb.tt", "#{db_migrate_path}/create_#{identity.invitations_table}.rb" if invitable?
|
|
529
|
+
migration_template "migrations/create_pending_registrations_migration.rb.tt", "#{db_migrate_path}/create_#{identity.pending_registrations_table}.rb" if pending_registration_model?
|
|
530
|
+
migration_template "migrations/create_omniauth_identities_migration.rb.tt", "#{db_migrate_path}/create_#{identity.omniauth_identities_table}.rb" if omniauth?
|
|
531
|
+
migration_template "migrations/add_guest_to_users_migration.rb.tt", "#{db_migrate_path}/add_guest_to_#{identity.table}.rb" if guestable?
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def create_models
|
|
535
|
+
template "models/current.rb.tt", "app/models/#{identity.current_file}.rb"
|
|
536
|
+
template "models/user.rb.tt", "app/models/#{identity.singular}.rb"
|
|
537
|
+
template "models/session.rb.tt", "app/models/#{identity.session_file}.rb"
|
|
538
|
+
template "models/api_token.rb.tt", "app/models/#{identity.api_token_file}.rb" if api_tokens?
|
|
539
|
+
|
|
540
|
+
template "models/concerns/principals.rb.tt", shared_concern_path(identity.principals_concern_file)
|
|
541
|
+
template "models/concerns/password_policy.rb.tt", shared_concern_path(identity.password_policy_concern_file) if password?
|
|
542
|
+
template "models/concerns/authenticatable.rb.tt", "app/models/#{identity.singular}/authenticatable.rb"
|
|
543
|
+
template "models/concerns/password_rotatable.rb.tt", "app/models/#{identity.singular}/password_rotatable.rb" if password_rotatable?
|
|
544
|
+
template "models/concerns/password_historical.rb.tt", "app/models/#{identity.singular}/password_historical.rb" if password_historical?
|
|
545
|
+
template "models/concerns/recoverable.rb.tt", "app/models/#{identity.singular}/recoverable.rb" if recoverable?
|
|
546
|
+
template "models/concerns/magic_linkable.rb.tt", "app/models/#{identity.singular}/magic_linkable.rb" if magic_link?
|
|
547
|
+
template "models/concerns/second_factor.rb.tt", "app/models/#{identity.singular}/second_factor.rb" if second_factor?
|
|
548
|
+
template "models/concerns/deadboltable.rb.tt", "app/models/#{identity.singular}/deadboltable.rb" if deadboltable?
|
|
549
|
+
template "models/concerns/bannable.rb.tt", "app/models/#{identity.singular}/bannable.rb" if bannable?
|
|
550
|
+
template "models/concerns/email_verifiable.rb.tt", "app/models/#{identity.singular}/email_verifiable.rb" if email_change_verification?
|
|
551
|
+
template "models/concerns/phone_verifiable.rb.tt", "app/models/#{identity.singular}/phone_verifiable.rb" if phone_change_verification?
|
|
552
|
+
template "models/concerns/sms_challengeable.rb.tt", "app/models/#{identity.singular}/sms_challengeable.rb" if sms?
|
|
553
|
+
|
|
554
|
+
if trackable?
|
|
555
|
+
template "models/event.rb.tt", "app/models/#{identity.event_file}.rb"
|
|
556
|
+
template "models/concerns/eventable.rb.tt", shared_concern_path(identity.namespaced_class("Eventable").underscore)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
template "models/recovery_code.rb.tt", "app/models/#{identity.recovery_code_file}.rb" if second_factor?
|
|
560
|
+
template "models/webauthn_credential.rb.tt", "app/models/#{identity.webauthn_credential_file}.rb" if webauthn_credentials?
|
|
561
|
+
template "models/password_history.rb.tt", "app/models/#{identity.password_history_file}.rb" if password_historical?
|
|
562
|
+
|
|
563
|
+
template "models/pending_registration.rb.tt", "app/models/#{identity.pending_registration_file}.rb" if pending_registration_model?
|
|
564
|
+
template "models/omniauth_identity.rb.tt", "app/models/#{identity.omniauth_identity_file}.rb" if omniauth?
|
|
565
|
+
|
|
566
|
+
template "models/concerns/adminable.rb.tt", "app/models/#{identity.singular}/adminable.rb" if adminable?
|
|
567
|
+
|
|
568
|
+
if teams?
|
|
569
|
+
template "models/team.rb.tt", "app/models/#{identity.team_file}.rb"
|
|
570
|
+
template "models/membership.rb.tt", "app/models/#{identity.membership_file}.rb"
|
|
571
|
+
template "models/concerns/team_member.rb.tt", "app/models/#{identity.singular}/team_member.rb"
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
if invitable?
|
|
575
|
+
template "models/invitation.rb.tt", "app/models/#{identity.invitation_file}.rb"
|
|
576
|
+
template "models/concerns/invitable.rb.tt", "app/models/#{identity.singular}/invitable.rb"
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
template "models/concerns/guestable.rb.tt", "app/models/#{identity.singular}/guestable.rb" if guestable?
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def create_controllers
|
|
583
|
+
template "controllers/concerns/authentication.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}.rb"
|
|
584
|
+
template "controllers/concerns/api_token_authentication.rb.tt", "app/controllers/concerns/#{identity.nested_path("api_token_authentication")}.rb" if api_tokens?
|
|
585
|
+
# Nested under the identity's concern rather than a flat app/controllers/concerns/
|
|
586
|
+
# because it reads that identity's config, routes and sudo subject — a second
|
|
587
|
+
# identity would otherwise clobber the first's copy (the EasyDevLogin shape).
|
|
588
|
+
template "controllers/concerns/authentication/sudoable.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/sudoable.rb" if sudoable?
|
|
589
|
+
template "controllers/concerns/authentication/webauthn_ceremony.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/webauthn_ceremony.rb" if webauthn_credentials?
|
|
590
|
+
template "controllers/concerns/authentication/registering.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/registering.rb" if pending_registration_model?
|
|
591
|
+
template "controllers/concerns/authorization.rb.tt", "app/controllers/concerns/#{identity.authorization_concern_file}.rb" if authorization?
|
|
592
|
+
|
|
593
|
+
if captchable?
|
|
594
|
+
template "controllers/concerns/captchable.rb.tt", "app/controllers/concerns/captchable.rb" unless File.exist?(in_destination("app/controllers/concerns/captchable.rb"))
|
|
595
|
+
# Provider dispatch + the Turnstile strategy. app/lib is autoloaded, and the
|
|
596
|
+
# Captcha namespace is where a second provider (Recaptcha, Hcaptcha) drops in.
|
|
597
|
+
template "lib/captcha.rb.tt", "app/lib/captcha.rb" unless File.exist?(in_destination("app/lib/captcha.rb"))
|
|
598
|
+
template "lib/captcha/turnstile.rb.tt", "app/lib/captcha/turnstile.rb" unless File.exist?(in_destination("app/lib/captcha/turnstile.rb"))
|
|
599
|
+
template "lib/captcha/null.rb.tt", "app/lib/captcha/null.rb" unless File.exist?(in_destination("app/lib/captcha/null.rb"))
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
if sms_delivery?
|
|
603
|
+
# The SMS seam, identity-free and shared: an SMS provider is app-global in
|
|
604
|
+
# practice, so the first identity to need one writes these and later ones
|
|
605
|
+
# reuse them. Same first-writer-wins shape as app/lib/captcha.rb, and the
|
|
606
|
+
# same caveat — sms.rb bakes in its writer's config namespace. Keep the two
|
|
607
|
+
# seams consistent if that coupling ever gets a cleaner resolution.
|
|
608
|
+
template "lib/sms.rb.tt", "app/lib/sms.rb" unless File.exist?(in_destination("app/lib/sms.rb"))
|
|
609
|
+
template "lib/sms/twilio.rb.tt", "app/lib/sms/twilio.rb" unless File.exist?(in_destination("app/lib/sms/twilio.rb"))
|
|
610
|
+
template "lib/sms/log.rb.tt", "app/lib/sms/log.rb" unless File.exist?(in_destination("app/lib/sms/log.rb"))
|
|
611
|
+
template "jobs/sms_delivery_job.rb.tt", "app/jobs/sms_delivery_job.rb" unless File.exist?(in_destination("app/jobs/sms_delivery_job.rb"))
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
if easy_dev_login?
|
|
615
|
+
if password?
|
|
616
|
+
# A password form already collects an email, so the shortcut piggybacks
|
|
617
|
+
# on it (type any email, password ignored) — no dedicated endpoint needed.
|
|
618
|
+
template "controllers/concerns/authentication/easy_dev_login.rb.tt", "app/controllers/concerns/#{identity.authentication_concern_file}/easy_dev_login.rb"
|
|
619
|
+
else
|
|
620
|
+
# Passwordless: there's no credential form to ride, so ship an explicit
|
|
621
|
+
# dev-only endpoint the email field on the sign-in page posts to.
|
|
622
|
+
template "controllers/users/easy_dev_logins_controller.rb.tt", "app/controllers/#{identity.controller_module}/easy_dev_logins_controller.rb"
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
if identity.namespaced?
|
|
627
|
+
# A namespaced identity never touches the shared ApplicationController — its
|
|
628
|
+
# authentication concern (and Authorization/Captchable, if present) is
|
|
629
|
+
# wired into its own BaseController instead, so a second identity's
|
|
630
|
+
# inclusion can never collide with the first's.
|
|
631
|
+
template "controllers/base_controller.rb.tt", "app/controllers/#{identity.base_controller_file}.rb"
|
|
632
|
+
else
|
|
633
|
+
inject_into_class "app/controllers/application_controller.rb", "ApplicationController" do
|
|
634
|
+
# Authentication first so the session is resumed before Authorization's
|
|
635
|
+
# gates run; Authorization's before_actions register after and run after.
|
|
636
|
+
includes = " include #{identity.authentication_concern}\n"
|
|
637
|
+
includes += " include #{identity.authorization_concern}\n" if authorization?
|
|
638
|
+
includes += " include Captchable\n" if captchable?
|
|
639
|
+
"#{includes}\n"
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
template "controllers/users/sessions_controller.rb.tt", "app/controllers/#{identity.controller_module}/sessions_controller.rb"
|
|
644
|
+
template "controllers/users/registrations_controller.rb.tt", "app/controllers/#{identity.controller_module}/registrations_controller.rb" if typed_registration?
|
|
645
|
+
template "controllers/users/password_resets_controller.rb.tt", "app/controllers/#{identity.controller_module}/password_resets_controller.rb" if recoverable?
|
|
646
|
+
template "controllers/users/magic_link_controller.rb.tt", "app/controllers/#{identity.controller_module}/magic_link_controller.rb" if magic_link?
|
|
647
|
+
template "controllers/users/sudos_controller.rb.tt", "app/controllers/#{identity.controller_module}/sudos_controller.rb" if sudoable?
|
|
648
|
+
template "controllers/users/omniauth_controller.rb.tt", "app/controllers/#{identity.controller_module}/omniauth_controller.rb" if omniauth?
|
|
649
|
+
template "controllers/users/omniauth/registrations_controller.rb.tt", "app/controllers/#{identity.controller_module}/omniauth/registrations_controller.rb" if omniauth_registration?
|
|
650
|
+
template "controllers/users/passkeys/sessions_controller.rb.tt", "app/controllers/#{identity.controller_module}/passkeys/sessions_controller.rb" if passkey?
|
|
651
|
+
template "controllers/users/sms_sessions_controller.rb.tt", "app/controllers/#{identity.controller_module}/sms_sessions_controller.rb" if sms_code?
|
|
652
|
+
template "controllers/users/registrations/phone_verifications_controller.rb.tt", "app/controllers/#{identity.controller_module}/registrations/phone_verifications_controller.rb" if phone_gated_registration?
|
|
653
|
+
|
|
654
|
+
if second_factor?
|
|
655
|
+
# Recovery codes back whichever factors the build has, so they are the one
|
|
656
|
+
# piece that follows second_factor? rather than a particular factor.
|
|
657
|
+
template "controllers/users/multi_factor_authentication/challenge/recovery_codes_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/recovery_codes_controller.rb"
|
|
658
|
+
template "controllers/users/multi_factor_authentication/challenge/totps_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/totps_controller.rb" if totp?
|
|
659
|
+
template "controllers/users/multi_factor_authentication/challenge/security_keys_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/security_keys_controller.rb" if security_keys?
|
|
660
|
+
template "controllers/users/multi_factor_authentication/challenge/sms_controller.rb.tt", "app/controllers/#{identity.controller_module}/multi_factor_authentication/challenge/sms_controller.rb" if sms_second_factor?
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
template "controllers/settings_controller.rb.tt", "app/controllers/#{identity.settings_controller_file}.rb"
|
|
664
|
+
template "controllers/settings/dashboard_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/dashboard_controller.rb"
|
|
665
|
+
template "controllers/settings/passwords_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/passwords_controller.rb" if password?
|
|
666
|
+
template "controllers/settings/emails_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/emails_controller.rb" if email_changeable?
|
|
667
|
+
template "controllers/settings/phones_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/phones_controller.rb" if phone_changeable?
|
|
668
|
+
template "controllers/settings/email_verifications_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/email_verifications_controller.rb" if email_change_verification?
|
|
669
|
+
template "controllers/users/email_verifications_controller.rb.tt", "app/controllers/#{identity.controller_module}/email_verifications_controller.rb" if email_change_verification?
|
|
670
|
+
template "erb/users/email_verifications/show.html.erb.tt", "app/views/#{identity.controller_module}/email_verifications/show.html.erb" if email_change_verification?
|
|
671
|
+
template "controllers/users/registrations/email_verifications_controller.rb.tt", "app/controllers/#{identity.controller_module}/registrations/email_verifications_controller.rb" if email_gated_registration?
|
|
672
|
+
template "controllers/settings/phone_verifications_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/phone_verifications_controller.rb" if phone_change_verification?
|
|
673
|
+
template "controllers/settings/sessions_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/sessions_controller.rb"
|
|
674
|
+
template "controllers/settings/api_tokens_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/api_tokens_controller.rb" if api_tokens?
|
|
675
|
+
template "controllers/settings/users_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/users_controller.rb"
|
|
676
|
+
template "controllers/settings/passkeys_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/passkeys_controller.rb" if passkey?
|
|
677
|
+
template "controllers/settings/omniauth_identities_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/omniauth_identities_controller.rb" if omniauth?
|
|
678
|
+
template "controllers/users/credential_enrollments_controller.rb.tt", "app/controllers/#{identity.controller_module}/credential_enrollments_controller.rb" if registration_credential_page?
|
|
679
|
+
template "controllers/users/credential_enrollments/passwords_controller.rb.tt", "app/controllers/#{identity.controller_module}/credential_enrollments/passwords_controller.rb" if pre_account_password_enrollment?
|
|
680
|
+
template "controllers/users/credential_enrollments/passkeys_controller.rb.tt", "app/controllers/#{identity.controller_module}/credential_enrollments/passkeys_controller.rb" if pre_account_passkey_enrollment?
|
|
681
|
+
|
|
682
|
+
if second_factor?
|
|
683
|
+
template "controllers/settings/multi_factor_authentication/recovery_codes_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/recovery_codes_controller.rb"
|
|
684
|
+
template "controllers/settings/multi_factor_authentication/authenticators_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/authenticators_controller.rb" if totp?
|
|
685
|
+
template "controllers/settings/multi_factor_authentication/security_keys_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys_controller.rb" if security_keys?
|
|
686
|
+
template "controllers/settings/multi_factor_authentication/sms_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/sms_controller.rb" if sms_second_factor?
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
if trackable?
|
|
690
|
+
template "controllers/settings/authentications/events_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/authentications/events_controller.rb"
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
if admin_dashboard?
|
|
694
|
+
template "controllers/admin_controller.rb.tt", "app/controllers/#{identity.nested_path("admin_controller")}.rb"
|
|
695
|
+
template "controllers/admin/dashboard_controller.rb.tt", "app/controllers/#{identity.nested_path("admin")}/dashboard_controller.rb"
|
|
696
|
+
template "controllers/admin/users_controller.rb.tt", "app/controllers/#{identity.nested_path("admin")}/users_controller.rb"
|
|
697
|
+
template "controllers/admin/sessions_controller.rb.tt", "app/controllers/#{identity.nested_path("admin")}/sessions_controller.rb"
|
|
698
|
+
|
|
699
|
+
# Deliberately outside the admin area: ending impersonation is the one part
|
|
700
|
+
# of the flow that must work while the admin area is shut.
|
|
701
|
+
template "controllers/impersonations_controller.rb.tt", "app/controllers/#{identity.nested_path("impersonations_controller")}.rb" if impersonatable?
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
if teams?
|
|
705
|
+
template "controllers/teams_controller.rb.tt", "app/controllers/#{identity.nested_path("teams_controller")}.rb"
|
|
706
|
+
template "controllers/active_teams_controller.rb.tt", "app/controllers/#{identity.nested_path("active_teams_controller")}.rb" if session_carries_team?
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
if invitable?
|
|
710
|
+
template "controllers/invitations_controller.rb.tt", "app/controllers/#{identity.nested_path("invitations_controller")}.rb"
|
|
711
|
+
template "erb/invitations/show.html.erb.tt", "app/views/#{identity.nested_path("invitations")}/show.html.erb" if teams?
|
|
712
|
+
template "controllers/settings/invitations_controller.rb.tt", "app/controllers/#{identity.settings_path_prefix}/invitations_controller.rb"
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def create_mailers
|
|
717
|
+
template "mailers/user_mailer.rb.tt", "app/mailers/#{identity.mailer_file}.rb" if sends_email?
|
|
718
|
+
return unless sends_email? && !File.exist?(in_destination("app/jobs/authnz_eleven_mail_delivery_job.rb"))
|
|
719
|
+
|
|
720
|
+
template "jobs/authnz_eleven_mail_delivery_job.rb.tt", "app/jobs/authnz_eleven_mail_delivery_job.rb"
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def create_deferred_job
|
|
724
|
+
return unless coy? && !File.exist?(in_destination("app/jobs/authnz_eleven_deferred_job.rb"))
|
|
725
|
+
|
|
726
|
+
template "jobs/authnz_eleven_deferred_job.rb.tt", "app/jobs/authnz_eleven_deferred_job.rb"
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def install_javascript
|
|
730
|
+
return unless webauthn_credentials?
|
|
731
|
+
|
|
732
|
+
template "javascript/controllers/webauthn_controller.js.tt", "app/javascript/controllers/webauthn_controller.js"
|
|
733
|
+
run "bin/importmap pin @rails/request.js" if importmaps?
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def create_tasks
|
|
737
|
+
task_path = identity.namespaced? ? "lib/tasks/authnz_eleven_#{identity.singular}.rake" : "lib/tasks/authnz_eleven.rake"
|
|
738
|
+
template "tasks/authnz_eleven.rake.tt", task_path if guestable? || timeoutable? || pending_registration_model?
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Drop commented Solid Queue schedules for the sweeps next to whatever recurring
|
|
742
|
+
# tasks the app already has. Only touch the file if it exists (no Solid Queue,
|
|
743
|
+
# no file — the rake tasks + post-install note still cover it), and keep them
|
|
744
|
+
# commented so they never collide with the app's real schedule. Each block is
|
|
745
|
+
# guarded independently so re-running the generator, or adding a flag later,
|
|
746
|
+
# appends only what is missing.
|
|
747
|
+
def schedule_recurring_tasks
|
|
748
|
+
return unless timeoutable? || guestable? || pending_registration_model?
|
|
749
|
+
return unless File.exist?("config/recurring.yml")
|
|
750
|
+
|
|
751
|
+
existing = File.read("config/recurring.yml")
|
|
752
|
+
|
|
753
|
+
if pending_registration_model? && !existing.include?("delete_expired_pending_registrations")
|
|
754
|
+
append_to_file "config/recurring.yml", <<~YAML
|
|
755
|
+
|
|
756
|
+
# delete_expired_pending_registrations:
|
|
757
|
+
# command: "#{identity.pending_registration_class}.expired.destroy_all"
|
|
758
|
+
# schedule: every day at 2am
|
|
759
|
+
YAML
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
if timeoutable? && !existing.include?("delete_timed_out_sessions")
|
|
763
|
+
append_to_file "config/recurring.yml", <<~YAML
|
|
764
|
+
|
|
765
|
+
# delete_timed_out_sessions:
|
|
766
|
+
# command: "#{identity.session_class}.timed_out.delete_all"
|
|
767
|
+
# schedule: every day at 3am
|
|
768
|
+
YAML
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
return unless guestable?
|
|
772
|
+
return if existing.include?("delete_expired_guests")
|
|
773
|
+
|
|
774
|
+
append_to_file "config/recurring.yml", <<~YAML
|
|
775
|
+
|
|
776
|
+
# delete_expired_guests:
|
|
777
|
+
# command: "#{identity.class_name}.expired_guests.find_each(&:destroy)"
|
|
778
|
+
# schedule: every day at 4am
|
|
779
|
+
YAML
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def create_tests
|
|
783
|
+
template "test_unit/fixtures/users.yml.tt", "test/fixtures/#{identity.table}.yml"
|
|
784
|
+
template "test_unit/mailers/user_mailer_test.rb.tt", "test/mailers/#{identity.mailer_file}_test.rb" if sends_email?
|
|
785
|
+
|
|
786
|
+
if teams?
|
|
787
|
+
template "test_unit/fixtures/teams.yml.tt", "test/fixtures/#{identity.team_file.pluralize}.yml"
|
|
788
|
+
template "test_unit/fixtures/memberships.yml.tt", "test/fixtures/#{identity.membership_file.pluralize}.yml"
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
template "test_unit/controllers/users/sessions_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/sessions_controller_test.rb"
|
|
792
|
+
template "test_unit/controllers/account_existence_parity_test.rb.tt", "test/controllers/#{identity.controller_module}/account_existence_parity_test.rb" if coy?
|
|
793
|
+
template "test_unit/controllers/session_lifecycle_test.rb.tt", "test/controllers/#{identity.controller_module}/session_lifecycle_test.rb" if timeoutable? || rememberable? || last_seenable? || max_sessionable?
|
|
794
|
+
template "test_unit/controllers/security_notifications_test.rb.tt", "test/controllers/#{identity.controller_module}/security_notifications_test.rb" if security_notifications?
|
|
795
|
+
template "test_unit/controllers/settings/api_tokens_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/api_tokens_controller_test.rb" if api_tokens?
|
|
796
|
+
template "test_unit/models/api_token_test.rb.tt", "test/models/#{identity.api_token_file}_test.rb" if api_tokens?
|
|
797
|
+
template "test_unit/controllers/api_token_authentication_test.rb.tt", "test/controllers/#{identity.controller_module}/api_token_authentication_test.rb" if api_tokens?
|
|
798
|
+
template "test_unit/controllers/credential_enrollment_test.rb.tt", "test/controllers/#{identity.controller_module}/credential_enrollment_test.rb" if credential_staged_registration?
|
|
799
|
+
template "test_unit/controllers/passkey_ceremony_test.rb.tt", "test/controllers/#{identity.controller_module}/passkey_ceremony_test.rb" if passkey?
|
|
800
|
+
template "test_unit/controllers/security_key_ceremony_test.rb.tt", "test/controllers/#{identity.controller_module}/security_key_ceremony_test.rb" if security_keys?
|
|
801
|
+
template "test_unit/controllers/password_rotation_test.rb.tt", "test/controllers/#{identity.controller_module}/password_rotation_test.rb" if password_rotatable?
|
|
802
|
+
template "test_unit/models/password_history_test.rb.tt", "test/models/#{identity.password_history_file}_reuse_test.rb" if password_historical?
|
|
803
|
+
template "test_unit/models/sms_challengeable_test.rb.tt", "test/models/#{identity.singular}/sms_challengeable_test.rb" if sms?
|
|
804
|
+
template "test_unit/models/session_test.rb.tt", "test/models/#{identity.session_file}_test.rb" if timeoutable?
|
|
805
|
+
template "test_unit/controllers/users/registrations_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/registrations_controller_test.rb" if typed_registration?
|
|
806
|
+
template "test_unit/controllers/users/password_resets_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/password_resets_controller_test.rb" if recoverable?
|
|
807
|
+
template "test_unit/controllers/users/magic_link_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/magic_link_controller_test.rb" if magic_link?
|
|
808
|
+
template "test_unit/controllers/users/sudos_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/sudos_controller_test.rb" if sudoable?
|
|
809
|
+
template "test_unit/controllers/users/omniauth_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/omniauth_controller_test.rb" if omniauth?
|
|
810
|
+
template "test_unit/controllers/settings/omniauth_identities_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/omniauth_identities_controller_test.rb" if omniauth?
|
|
811
|
+
template "test_unit/controllers/users/multi_factor_authentication/challenge_test.rb.tt", "test/controllers/#{identity.controller_module}/multi_factor_authentication/challenge_test.rb" if second_factor?
|
|
812
|
+
template "test_unit/controllers/settings/email_verifications_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/email_verifications_controller_test.rb" if email_change_verification?
|
|
813
|
+
template "test_unit/controllers/users/email_verifications_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/email_verifications_controller_test.rb" if email_change_verification?
|
|
814
|
+
template "test_unit/controllers/users/registrations/email_verifications_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/registrations/email_verifications_controller_test.rb" if email_gated_registration?
|
|
815
|
+
template "test_unit/controllers/settings/phone_verifications_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/phone_verifications_controller_test.rb" if phone_change_verification?
|
|
816
|
+
template "test_unit/controllers/users/sms_sessions_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/sms_sessions_controller_test.rb" if sms_code?
|
|
817
|
+
template "test_unit/controllers/users/registrations/phone_verifications_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/registrations/phone_verifications_controller_test.rb" if phone_gated_registration?
|
|
818
|
+
|
|
819
|
+
# Authenticated-area tests read Current.user, which now resolves for every
|
|
820
|
+
# identity (each namespaced session aliases its principal to :user — see
|
|
821
|
+
# models/session.rb.tt), so these ship for namespaced identities too.
|
|
822
|
+
template "test_unit/controllers/settings/management_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/management_test.rb"
|
|
823
|
+
template "test_unit/controllers/settings/authentications/events_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/authentications/events_controller_test.rb" if trackable?
|
|
824
|
+
template "test_unit/controllers/settings/multi_factor_authentication/management_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/multi_factor_authentication/management_test.rb" if second_factor?
|
|
825
|
+
|
|
826
|
+
if invitable?
|
|
827
|
+
template "test_unit/controllers/invitations_controller_test.rb.tt", "test/controllers/#{identity.nested_path("invitations_controller_test")}.rb"
|
|
828
|
+
template "test_unit/controllers/settings/invitations_controller_test.rb.tt", "test/controllers/#{identity.settings_path_prefix}/invitations_controller_test.rb"
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
template "test_unit/controllers/teams_controller_test.rb.tt", "test/controllers/#{identity.nested_path("teams_controller_test")}.rb" if teams?
|
|
832
|
+
|
|
833
|
+
if admin_dashboard?
|
|
834
|
+
template "test_unit/controllers/admin/access_test.rb.tt", "test/controllers/#{identity.nested_path("admin")}/access_test.rb"
|
|
835
|
+
template "test_unit/controllers/admin/multi_factor_authentication_test.rb.tt", "test/controllers/#{identity.nested_path("admin")}/multi_factor_authentication_test.rb" if second_factor?
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
template "test_unit/models/user/guestable_test.rb.tt", "test/models/#{identity.singular}/guestable_test.rb" if guestable?
|
|
839
|
+
template "test_unit/models/user/sign_in_methods_test.rb.tt", "test/models/#{identity.singular}/sign_in_methods_test.rb" if removable_credentials?
|
|
840
|
+
template "test_unit/models/user/recovery_codes_test.rb.tt", "test/models/#{identity.singular}/recovery_codes_test.rb" if both_second_factors?
|
|
841
|
+
template "test_unit/controllers/guest_access_test.rb.tt", "test/controllers/#{identity.controller_module}/guest_access_test.rb" if guestable?
|
|
842
|
+
template "test_unit/models/user_test.rb.tt", "test/models/#{identity.singular}_test.rb"
|
|
843
|
+
template "test_unit/controllers/users/omniauth/registrations_controller_test.rb.tt", "test/controllers/#{identity.controller_module}/omniauth/registrations_controller_test.rb" if omniauth_registration?
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def create_views
|
|
847
|
+
template "erb/user_mailer/registration_email_verification.html.erb.tt", "app/views/#{identity.mailer_file}/registration_email_verification.html.erb" if email_gated_registration?
|
|
848
|
+
template "erb/user_mailer/email_change_verification.html.erb.tt", "app/views/#{identity.mailer_file}/email_change_verification.html.erb" if email_change_verification?
|
|
849
|
+
template "erb/user_mailer/password_reset.html.erb.tt", "app/views/#{identity.mailer_file}/password_reset.html.erb" if recoverable?
|
|
850
|
+
template "erb/user_mailer/magic_link.html.erb.tt", "app/views/#{identity.mailer_file}/magic_link.html.erb" if magic_link?
|
|
851
|
+
template "erb/user_mailer/invitation.html.erb.tt", "app/views/#{identity.mailer_file}/invitation.html.erb" if email_invitable?
|
|
852
|
+
template "erb/user_mailer/security_notification.html.erb.tt", "app/views/#{identity.mailer_file}/security_notification.html.erb" if security_notifications?
|
|
853
|
+
|
|
854
|
+
template "erb/shared/_captcha.html.erb.tt", "app/views/shared/_captcha.html.erb" if captchable? && !File.exist?(in_destination("app/views/shared/_captcha.html.erb"))
|
|
855
|
+
template "erb/shared/_authnz_eleven_sudo_challenge.html.erb.tt", partial_file(identity.sudo_partial) if sudoable?
|
|
856
|
+
|
|
857
|
+
template "erb/users/sessions/new.html.erb.tt", "app/views/#{identity.controller_module}/sessions/new.html.erb"
|
|
858
|
+
template "erb/users/registrations/new.html.erb.tt", "app/views/#{identity.controller_module}/registrations/new.html.erb" if typed_registration?
|
|
859
|
+
template "erb/users/omniauth/registrations/new.html.erb.tt", "app/views/#{identity.controller_module}/omniauth/registrations/new.html.erb" if omniauth_registration?
|
|
860
|
+
template "erb/users/password_resets/new.html.erb.tt", "app/views/#{identity.controller_module}/password_resets/new.html.erb" if recoverable?
|
|
861
|
+
template "erb/users/password_resets/edit.html.erb.tt", "app/views/#{identity.controller_module}/password_resets/edit.html.erb" if recoverable?
|
|
862
|
+
if magic_link?
|
|
863
|
+
template "erb/users/magic_link/new.html.erb.tt", "app/views/#{identity.controller_module}/magic_link/new.html.erb"
|
|
864
|
+
template "erb/users/magic_link/edit.html.erb.tt", "app/views/#{identity.controller_module}/magic_link/edit.html.erb"
|
|
865
|
+
end
|
|
866
|
+
if sms_code?
|
|
867
|
+
template "erb/users/sms_sessions/new.html.erb.tt", "app/views/#{identity.controller_module}/sms_sessions/new.html.erb"
|
|
868
|
+
template "erb/users/sms_sessions/edit.html.erb.tt", "app/views/#{identity.controller_module}/sms_sessions/edit.html.erb"
|
|
869
|
+
end
|
|
870
|
+
template "erb/users/registrations/phone_verifications/new.html.erb.tt", "app/views/#{identity.controller_module}/registrations/phone_verifications/new.html.erb" if phone_gated_registration?
|
|
871
|
+
if email_gated_registration?
|
|
872
|
+
template "erb/users/registrations/email_verifications/new.html.erb.tt", "app/views/#{identity.controller_module}/registrations/email_verifications/new.html.erb"
|
|
873
|
+
template "erb/users/registrations/email_verifications/show.html.erb.tt", "app/views/#{identity.controller_module}/registrations/email_verifications/show.html.erb"
|
|
874
|
+
end
|
|
875
|
+
template "erb/users/sudos/new.html.erb.tt", "app/views/#{identity.controller_module}/sudos/new.html.erb" if sudoable?
|
|
876
|
+
|
|
877
|
+
if second_factor?
|
|
878
|
+
template "erb/users/multi_factor_authentication/challenge/recovery_codes/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/recovery_codes/new.html.erb"
|
|
879
|
+
template "erb/users/multi_factor_authentication/challenge/totps/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/totps/new.html.erb" if totp?
|
|
880
|
+
template "erb/users/multi_factor_authentication/challenge/security_keys/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/security_keys/new.html.erb" if security_keys?
|
|
881
|
+
template "erb/users/multi_factor_authentication/challenge/sms/new.html.erb.tt", "app/views/#{identity.controller_module}/multi_factor_authentication/challenge/sms/new.html.erb" if sms_second_factor?
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
template "erb/settings/dashboard/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/dashboard/index.html.erb"
|
|
885
|
+
template "erb/settings/passwords/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passwords/edit.html.erb" if password?
|
|
886
|
+
template "erb/settings/emails/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/emails/edit.html.erb" if email_changeable?
|
|
887
|
+
template "erb/settings/phones/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/phones/edit.html.erb" if phone_changeable?
|
|
888
|
+
template "erb/settings/phone_verifications/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/phone_verifications/new.html.erb" if phone_change_verification?
|
|
889
|
+
template "erb/settings/sessions/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/sessions/index.html.erb"
|
|
890
|
+
template "erb/settings/api_tokens/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/api_tokens/index.html.erb" if api_tokens?
|
|
891
|
+
template "erb/settings/api_tokens/create.html.erb.tt", "app/views/#{identity.settings_path_prefix}/api_tokens/create.html.erb" if api_tokens?
|
|
892
|
+
template "erb/settings/users/show.html.erb.tt", "app/views/#{identity.settings_path_prefix}/users/show.html.erb"
|
|
893
|
+
|
|
894
|
+
if passkey?
|
|
895
|
+
template "erb/settings/passkeys/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passkeys/index.html.erb"
|
|
896
|
+
template "erb/settings/passkeys/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passkeys/new.html.erb"
|
|
897
|
+
template "erb/settings/passkeys/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/passkeys/edit.html.erb"
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
if omniauth?
|
|
901
|
+
template "erb/settings/omniauth_identities/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/omniauth_identities/index.html.erb"
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
template "erb/users/credential_enrollments/show.html.erb.tt", "app/views/#{identity.controller_module}/credential_enrollments/show.html.erb" if registration_credential_page?
|
|
905
|
+
template "erb/users/credential_enrollments/passwords/new.html.erb.tt", "app/views/#{identity.controller_module}/credential_enrollments/passwords/new.html.erb" if pre_account_password_enrollment?
|
|
906
|
+
template "erb/users/credential_enrollments/passkeys/new.html.erb.tt", "app/views/#{identity.controller_module}/credential_enrollments/passkeys/new.html.erb" if pre_account_passkey_enrollment?
|
|
907
|
+
|
|
908
|
+
if second_factor?
|
|
909
|
+
template "erb/settings/multi_factor_authentication/recovery_codes/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/recovery_codes/index.html.erb"
|
|
910
|
+
template "erb/settings/multi_factor_authentication/recovery_codes/create.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/recovery_codes/create.html.erb"
|
|
911
|
+
template "erb/settings/multi_factor_authentication/authenticators/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/authenticators/new.html.erb" if totp?
|
|
912
|
+
template "erb/settings/multi_factor_authentication/sms/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/sms/new.html.erb" if sms_second_factor?
|
|
913
|
+
|
|
914
|
+
if security_keys?
|
|
915
|
+
template "erb/settings/multi_factor_authentication/security_keys/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys/index.html.erb"
|
|
916
|
+
template "erb/settings/multi_factor_authentication/security_keys/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys/new.html.erb"
|
|
917
|
+
template "erb/settings/multi_factor_authentication/security_keys/edit.html.erb.tt", "app/views/#{identity.settings_path_prefix}/multi_factor_authentication/security_keys/edit.html.erb"
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
if trackable?
|
|
922
|
+
template "erb/settings/authentications/events/index.html.erb.tt", "app/views/#{identity.settings_path_prefix}/authentications/events/index.html.erb"
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
if admin_dashboard?
|
|
926
|
+
template "erb/admin/dashboard/index.html.erb.tt", "app/views/#{identity.nested_path("admin")}/dashboard/index.html.erb"
|
|
927
|
+
template "erb/admin/users/index.html.erb.tt", "app/views/#{identity.nested_path("admin")}/users/index.html.erb"
|
|
928
|
+
template "erb/admin/users/show.html.erb.tt", "app/views/#{identity.nested_path("admin")}/users/show.html.erb"
|
|
929
|
+
template "erb/admin/sessions/index.html.erb.tt", "app/views/#{identity.nested_path("admin")}/sessions/index.html.erb"
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
if teams?
|
|
933
|
+
template "erb/teams/index.html.erb.tt", "app/views/#{identity.nested_path("teams")}/index.html.erb"
|
|
934
|
+
template "erb/teams/new.html.erb.tt", "app/views/#{identity.nested_path("teams")}/new.html.erb"
|
|
935
|
+
template "erb/teams/show.html.erb.tt", "app/views/#{identity.nested_path("teams")}/show.html.erb" if scope_teams?
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
if invitable?
|
|
939
|
+
template "erb/settings/invitations/new.html.erb.tt", "app/views/#{identity.settings_path_prefix}/invitations/new.html.erb"
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# The signed-in/out chrome (Settings, Sign out, the doors) so a fresh install is
|
|
944
|
+
# navigable without the host app writing a header first. Every identity gets its
|
|
945
|
+
# own partial. The default identity's goes into the application layout; a namespaced
|
|
946
|
+
# identity is a separate portal, like an engine, so it gets its own layout copied
|
|
947
|
+
# from the application one with its own nav in place of the default's. A layout
|
|
948
|
+
# is edited only when it's still the stock Rails default, so a customized one is
|
|
949
|
+
# never touched — we print a one-line note instead.
|
|
950
|
+
def install_navigation
|
|
951
|
+
partial = partial_file(identity.nav_partial)
|
|
952
|
+
template "erb/shared/_authnz_eleven_nav.html.erb.tt", partial unless File.exist?(in_destination(partial))
|
|
953
|
+
|
|
954
|
+
application_layout = in_destination("app/views/layouts/application.html.erb")
|
|
955
|
+
layout = "app/views/layouts/#{identity.namespaced? ? identity.controller_module : "application"}.html.erb"
|
|
956
|
+
render_line = %(<%= render "#{identity.nav_partial}" %>)
|
|
957
|
+
|
|
958
|
+
unless File.exist?(application_layout)
|
|
959
|
+
@navigation_render_line = render_line
|
|
960
|
+
return
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
unless File.exist?(in_destination(layout))
|
|
964
|
+
create_file layout, File.read(application_layout).sub(%(<%= render "shared/authnz_eleven_nav" %>), render_line)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
contents = File.read(in_destination(layout))
|
|
968
|
+
if contents.include?(render_line)
|
|
969
|
+
# Already wired (a re-run); leave it be.
|
|
970
|
+
elsif contents.match?(%r{<body>\s*<%=\s*yield\s*%>\s*</body>}m)
|
|
971
|
+
# Stock Rails layout — safe to drop the render in right after <body>.
|
|
972
|
+
inject_into_file layout, "\n #{render_line}", after: "<body>"
|
|
973
|
+
else
|
|
974
|
+
# Customized layout: don't touch it, tell them where to add the line.
|
|
975
|
+
@navigation_render_line = render_line
|
|
976
|
+
end
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def add_routes
|
|
980
|
+
route(identity.namespaced? ? namespaced_route_block : route_block)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# The first thing anyone sees, so it stays a checklist: what must happen before
|
|
984
|
+
# the app runs, then what to know before it ships. Everything explanatory — how
|
|
985
|
+
# the deadbolt behaves, what a guest is, which tunables exist — lives in the
|
|
986
|
+
# README and the initializer, which is where someone looks for it a week later.
|
|
987
|
+
def show_post_install
|
|
988
|
+
return unless behavior == :invoke
|
|
989
|
+
|
|
990
|
+
say "\nauthnz_eleven installed.", :green
|
|
991
|
+
|
|
992
|
+
say "\nNext steps:", :green
|
|
993
|
+
next_steps.each_with_index { |(text, *lines), index| say_item("#{index + 1}.", text, lines) }
|
|
994
|
+
|
|
995
|
+
warnings = ship_warnings
|
|
996
|
+
unless warnings.empty?
|
|
997
|
+
say "\nBefore you ship:", :yellow
|
|
998
|
+
warnings.each { |warning| say_item("*", warning, [], :yellow) }
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
say "\n Config: config/initializers/#{identity.initializer_file}.rb"
|
|
1002
|
+
say " Start reading: app/controllers/concerns/#{identity.authentication_concern_file}.rb\n"
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
private
|
|
1006
|
+
|
|
1007
|
+
# ---------------------------------------------------------------------------
|
|
1008
|
+
# Post-install output
|
|
1009
|
+
#
|
|
1010
|
+
# The two lists show_post_install prints, and the wrapper that renders them.
|
|
1011
|
+
# ---------------------------------------------------------------------------
|
|
1012
|
+
|
|
1013
|
+
# Sentences are wrapped; the lines under them (commands, env vars, code) are
|
|
1014
|
+
# printed whole on their own rows so they can be found and copied.
|
|
1015
|
+
def say_item(marker, text, lines, color = nil)
|
|
1016
|
+
indent = " " * (marker.length + 3)
|
|
1017
|
+
rows = [+" #{marker} "]
|
|
1018
|
+
|
|
1019
|
+
text.split.each do |word|
|
|
1020
|
+
rows << indent.dup if rows.last.length + word.length > 78
|
|
1021
|
+
rows.last << word << " "
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
rows.each { |row| say row.rstrip, color }
|
|
1025
|
+
lines.each { |line| say "#{indent} #{line}", :cyan }
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# What has to happen before the generated app runs, in the order you hit it.
|
|
1029
|
+
# Each step is a sentence followed by the lines to type or paste.
|
|
1030
|
+
def next_steps
|
|
1031
|
+
[
|
|
1032
|
+
(["Install the new gems.", "bundle install"] if gem_dependencies?),
|
|
1033
|
+
["Run the migrations.", "bin/rails db:migrate"],
|
|
1034
|
+
["Add a root route. Generated redirects use root_path."],
|
|
1035
|
+
(["Make sure Action Mailer can deliver in each environment, with default_url_options set."] if sends_email?),
|
|
1036
|
+
(["Use a shared production cache across all app processes. Missing cache entries invalidate sign-in links and texted codes."] if magic_link? || sms?),
|
|
1037
|
+
(encryption_step if encryption?),
|
|
1038
|
+
(["Set the WebAuthn origin and relying-party ID for each environment.", "config/initializers/webauthn.rb"] if webauthn_credentials?),
|
|
1039
|
+
(["Add your Cloudflare Turnstile keys to credentials for production (or set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY).", "turnstile:", " site_key: ...", " secret_key: ..."] if captchable?),
|
|
1040
|
+
(["Add your Twilio keys to credentials for production (or set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_MESSAGING_SERVICE_SID). Until then, texts go to the Rails log.", "twilio:", " account_sid: ...", " auth_token: ...", " messaging_service_sid: ..."] if sms_delivery?),
|
|
1041
|
+
(["Your layout isn't the stock one, so add the navigation partial yourself:", @navigation_render_line] if @navigation_render_line),
|
|
1042
|
+
(["Keep identifiers out of your logs. You have no filter_parameter_logging.rb, so add this to an initializer:", @parameter_filter_line] if @parameter_filter_line),
|
|
1043
|
+
(["Add allow_guest_access to the controllers guests may use, and fill in #{identity.class_name}::Guestable#absorb_guest."] if guestable?),
|
|
1044
|
+
(sweep_step if sweep_tasks.any?)
|
|
1045
|
+
].compact
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def encryption_step
|
|
1049
|
+
["Create encryption keys and paste them into each credentials file you deploy with. Losing them loses #{encrypted_columns_phrase}.",
|
|
1050
|
+
"bin/rails db:encryption:init",
|
|
1051
|
+
"bin/rails credentials:edit"]
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def sweep_tasks
|
|
1055
|
+
[
|
|
1056
|
+
("delete_expired_pending_registrations" if pending_registration_model?),
|
|
1057
|
+
("delete_timed_out_sessions" if timeoutable?),
|
|
1058
|
+
("delete_expired_guests" if guestable?)
|
|
1059
|
+
].compact
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def sweep_step
|
|
1063
|
+
scheduled = " Commented entries for them are in config/recurring.yml." if File.exist?("config/recurring.yml")
|
|
1064
|
+
["Schedule these cleanup tasks. Nothing deletes expired rows for you.#{scheduled}",
|
|
1065
|
+
*sweep_tasks.map { |task| "bin/rails #{identity.rake_task(task)}" }]
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
# Risks this build can actually produce, one sentence of consequence each.
|
|
1069
|
+
# A feature that merely deserves explaining doesn't belong here.
|
|
1070
|
+
def ship_warnings
|
|
1071
|
+
[
|
|
1072
|
+
("There is no account recovery, so a user who loses their credential is locked out. Add --recoverable, --magic-link or --sms-code." unless recovery_path?),
|
|
1073
|
+
("Only configure OmniAuth providers that verify email addresses. Their addresses are trusted, so one that doesn't verify lets a stranger claim someone else's address." if omniauth? && email_verifiable?),
|
|
1074
|
+
(email_optional_warning if principals.email&.optional?),
|
|
1075
|
+
*sms_warnings
|
|
1076
|
+
].compact
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# Worth saying only where it bites: an account with no address either can't
|
|
1080
|
+
# sign in at all, or can't be recovered by the flows this build has.
|
|
1081
|
+
def email_optional_warning
|
|
1082
|
+
return "Email is optional and is your only identifier, so users who skip it can't sign in. Add --username or a required --phone." if login_principals.all? { |p| p.type == :email }
|
|
1083
|
+
|
|
1084
|
+
"Email is optional, so password reset and magic links can't reach some users." if recoverable? || magic_link?
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def sms_warnings
|
|
1088
|
+
return [] unless sms_delivery?
|
|
1089
|
+
|
|
1090
|
+
[
|
|
1091
|
+
("Phone numbers aren't verified (--no-verifiable), so anyone can register a number they don't own and lock out its real owner." if sms_code? && !phone_verifiable?),
|
|
1092
|
+
("There is no captcha. --captchable adds one to the signed-out forms, the main brake on bots." unless captchable?),
|
|
1093
|
+
("Also offer an authenticator app or passkey if you have compliance requirements. NIST SP 800-63B restricts SMS." if sms_only_authenticator?)
|
|
1094
|
+
].compact
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
def sms_only_authenticator?
|
|
1098
|
+
(sms_code? || sms_second_factor?) && !(passkey? || totp? || security_keys?)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# What the encryption step names as covered.
|
|
1102
|
+
def encrypted_columns_phrase
|
|
1103
|
+
columns = encrypted_pii? ? principals.select(&:channel?).map(&:column) : []
|
|
1104
|
+
columns.push("totp_secret") if totp?
|
|
1105
|
+
columns.to_sentence
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# Whether any flow in this build sends mail — the same set of conditions the
|
|
1109
|
+
# mailer template guards its methods with.
|
|
1110
|
+
def sends_email?
|
|
1111
|
+
email_gated_registration? || email_change_verification? || recoverable? ||
|
|
1112
|
+
magic_link? || email_invitable? || security_notifications?
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# ---------------------------------------------------------------------------
|
|
1116
|
+
# Generator plumbing
|
|
1117
|
+
# ---------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
# A --email / --phone value: one requiredness word, optionally followed by
|
|
1120
|
+
# "permanent". Principals.split_mode does the splitting; this only says whether
|
|
1121
|
+
# what it found is what a principal accepts.
|
|
1122
|
+
def principal_mode_valid?(value)
|
|
1123
|
+
requiredness, modifiers = AuthnzEleven::Principals.split_mode(value)
|
|
1124
|
+
|
|
1125
|
+
AuthnzEleven::Principals::REQUIREDNESS.include?(requiredness) &&
|
|
1126
|
+
(modifiers.empty? || AuthnzEleven::Principals.permanent?(modifiers))
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
# The invocation that produced this build, stamped into the initializer so an
|
|
1130
|
+
# app can say what it was generated from years later. Rebuilt from the flags
|
|
1131
|
+
# rather than read off ARGV, so it is the same command whether the generator
|
|
1132
|
+
# was run from the command line or invoked in a test.
|
|
1133
|
+
#
|
|
1134
|
+
# Only flags that moved off their default are named: a default-valued flag
|
|
1135
|
+
# would be noise, and re-running the printed command has to mean the same
|
|
1136
|
+
# thing under the version it names.
|
|
1137
|
+
def generation_command
|
|
1138
|
+
flags = self.class.class_options.except(*Rails::Generators::Base.class_options.keys).filter_map do |name, option|
|
|
1139
|
+
value = options[name.to_s]
|
|
1140
|
+
generation_flag(name, value) unless value == option.default
|
|
1141
|
+
end
|
|
1142
|
+
["bin/rails generate authnz_eleven", *flags]
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
def generation_flag(name, value)
|
|
1146
|
+
dashed = name.to_s.tr("_", "-")
|
|
1147
|
+
case value
|
|
1148
|
+
when true then "--#{dashed}"
|
|
1149
|
+
when false then "--no-#{dashed}"
|
|
1150
|
+
else "--#{dashed}=#{value}"
|
|
1151
|
+
end
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Wrapped with shell continuations so the comment stays readable and the
|
|
1155
|
+
# command stays runnable when a build carries a dozen flags.
|
|
1156
|
+
def generation_command_lines(width: 72)
|
|
1157
|
+
generation_command.each_with_object([]) do |word, lines|
|
|
1158
|
+
if lines.empty? || "#{lines.last} #{word}".length > width
|
|
1159
|
+
lines.last << " \\" unless lines.empty?
|
|
1160
|
+
lines << (lines.empty? ? +word : " #{word}")
|
|
1161
|
+
else
|
|
1162
|
+
lines.last << " #{word}"
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# A multi-line Ruby hash literal, punctuated here instead of in the template.
|
|
1168
|
+
#
|
|
1169
|
+
# Which entries a generated hash has depends on the flags, so a template that
|
|
1170
|
+
# writes its own commas is guessing which of its lines will end up last. In the
|
|
1171
|
+
# builds where it guesses wrong the literal trails a comma into its closing
|
|
1172
|
+
# brace (Style/TrailingCommaInHashLiteral), and where every entry happens to be
|
|
1173
|
+
# conditional it opens and closes over nothing at all. Hand over the entries
|
|
1174
|
+
# that actually exist and let this place the punctuation.
|
|
1175
|
+
#
|
|
1176
|
+
# +indent+ is the column the braces sit at; entries go two deeper. An entry may
|
|
1177
|
+
# be several lines (a comment above its pair), in which case the comma lands
|
|
1178
|
+
# after the last of them.
|
|
1179
|
+
def hash_literal(entries, indent:)
|
|
1180
|
+
return "{}" if entries.empty?
|
|
1181
|
+
|
|
1182
|
+
pad = " " * indent
|
|
1183
|
+
body = entries.map { |entry| entry.lines.map { |line| "#{pad} #{line.chomp}" }.join("\n") }
|
|
1184
|
+
"{\n#{body.join(",\n")}\n#{pad}}"
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
# The attribute and param names install_parameter_filters redacts: the ones this
|
|
1188
|
+
# build introduces that Rails' stock list doesn't already reach. It reaches most
|
|
1189
|
+
# of them by substring — :passw covers password_digest, :otp and :secret cover
|
|
1190
|
+
# totp_secret, :token covers the emailed token, :email covers both the column and
|
|
1191
|
+
# pending_email — which is why none of those repeat here.
|
|
1192
|
+
def parameter_filters
|
|
1193
|
+
filters = []
|
|
1194
|
+
# The provider's identifier for the account. Not a credential, but a strategy
|
|
1195
|
+
# may put anything it likes in it, and several put the address or the handle
|
|
1196
|
+
# there (the developer strategy uses the email) — which quietly undoes the
|
|
1197
|
+
# redaction of the email column sitting one line above it in an `inspect`.
|
|
1198
|
+
filters << "uid" if omniauth?
|
|
1199
|
+
# The number itself. phone_verified_at stays readable: it's a timestamp, and
|
|
1200
|
+
# knowing when a number was proved is worth more in a log than it costs.
|
|
1201
|
+
filters << "phone" if phone?
|
|
1202
|
+
# An invitation address, under a column name :email can't see.
|
|
1203
|
+
filters << "sent_to" if invitable?
|
|
1204
|
+
# params[:code] on the way in: a recovery code, a texted one-time code, or an
|
|
1205
|
+
# OAuth authorization code on the callback. This also covers recovery codes
|
|
1206
|
+
# before they are digested.
|
|
1207
|
+
filters << "code" if second_factor? || sms? || omniauth?
|
|
1208
|
+
# The authenticator code answering a sudo bar. Its own entry because the filter
|
|
1209
|
+
# above is anchored (so unrelated names containing "code" stay readable), so it doesn't
|
|
1210
|
+
# reach a prefixed name. sudo_password needs no entry — Rails' stock list
|
|
1211
|
+
# matches "passw" as a substring.
|
|
1212
|
+
filters << "sudo_code" if sudoable? && sudo_bars.include?(:totp)
|
|
1213
|
+
# The bearer token every emailed link arrives under.
|
|
1214
|
+
filters << "sid" if email_verifiable? || recoverable? || magic_link?
|
|
1215
|
+
# The identifier typed at sign-in, and its copy in the bounce URL. `email_hint`
|
|
1216
|
+
# the stock :email already reaches, and a username is not a secret.
|
|
1217
|
+
filters.push("login", "login_hint") if multi_login?
|
|
1218
|
+
filters << "#{login_field}_hint" if !multi_login? && login_field.to_s == "phone"
|
|
1219
|
+
# The unproved number, under a column name /\Aphone\z/ can't see.
|
|
1220
|
+
filters << "pending_phone" if phone_verifiable?
|
|
1221
|
+
filters
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# Anchored, rather than the bare symbols the stock list is written in. Those
|
|
1225
|
+
# match on substrings, and this list is the host app's: `uid` alone would also
|
|
1226
|
+
# swallow its `uuid` and `guid`, while `code` would hide unrelated names that
|
|
1227
|
+
# merely contain the same substring.
|
|
1228
|
+
def filter_regexp(name) = "/\\A#{name}\\z/"
|
|
1229
|
+
|
|
1230
|
+
# ---- the destination app --------------------------------------------------
|
|
1231
|
+
|
|
1232
|
+
def in_destination(relative_path) = File.join(destination_root, relative_path)
|
|
1233
|
+
|
|
1234
|
+
# A concern with a generic name (Principals, Eventable) sits in app/models/concerns
|
|
1235
|
+
# for the default identity; a namespaced identity's copy joins its other models.
|
|
1236
|
+
def shared_concern_path(file) = "app/models/#{"concerns/" unless identity.namespaced?}#{file}.rb"
|
|
1237
|
+
|
|
1238
|
+
def partial_file(name) = "app/views/#{File.dirname(name)}/_#{File.basename(name)}.html.erb"
|
|
1239
|
+
|
|
1240
|
+
# A second identity asks for gems the first one already added.
|
|
1241
|
+
def gem(name, *, **)
|
|
1242
|
+
super unless File.read(in_destination("Gemfile")).match?(/^\s*gem "#{name}"/)
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
def bcrypt_present?
|
|
1246
|
+
File.read(in_destination("Gemfile")).include?('gem "bcrypt"')
|
|
1247
|
+
rescue Errno::ENOENT
|
|
1248
|
+
false
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
def importmaps?
|
|
1252
|
+
File.exist?("config/importmap.rb")
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
# Every condition #add_gems writes a gem under.
|
|
1256
|
+
def gem_dependencies?
|
|
1257
|
+
password? || second_factor? || pwned? || strong_passwords? || omniauth? ||
|
|
1258
|
+
webauthn_credentials? || principals.email || phone?
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
def filter_parameters_line(names)
|
|
1262
|
+
"Rails.application.config.filter_parameters += [ #{names.map { |name| filter_regexp(name) }.join(", ")} ]"
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
# ---------------------------------------------------------------------------
|
|
1266
|
+
# What this build is
|
|
1267
|
+
#
|
|
1268
|
+
# The identity and principal sets, and the flag predicates derived from them.
|
|
1269
|
+
# Everything here answers a question about the build, and the expressions a
|
|
1270
|
+
# template needs sit beside the predicate that decides whether to emit them.
|
|
1271
|
+
# ---------------------------------------------------------------------------
|
|
1272
|
+
|
|
1273
|
+
# The identity being generated: derives the class name, table, sessions, route
|
|
1274
|
+
# and controller slugs, cookie, and path/account prefixes from --user-class
|
|
1275
|
+
# (default "User") and the scoping flags.
|
|
1276
|
+
def identity
|
|
1277
|
+
@identity ||= AuthnzEleven::Identity.new(
|
|
1278
|
+
class_name: options[:user_class],
|
|
1279
|
+
namespaced: options[:namespaced]
|
|
1280
|
+
)
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# The identifying attributes (principals) and their roles for this run — which
|
|
1284
|
+
# columns exist, which are login keys, which can receive messages. With no
|
|
1285
|
+
# principal flags this set is empty; validate_principal! rejects that shape.
|
|
1286
|
+
def principals
|
|
1287
|
+
@principals ||= AuthnzEleven::Principals.from_options(options)
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
# The sign-in login key when the form posts a single field. A multi-login
|
|
1291
|
+
# build dispatches between several principals instead.
|
|
1292
|
+
def login_principal = principals.login_principals.first
|
|
1293
|
+
def login_field = login_principal.column
|
|
1294
|
+
def multi_login? = principals.multi_login?
|
|
1295
|
+
|
|
1296
|
+
# A phone principal is present (--phone=required|optional). Gates the phonelib
|
|
1297
|
+
# gem, the phone column's validation, the registration/settings phone field, and
|
|
1298
|
+
# (3b) the SMS verification flow.
|
|
1299
|
+
def phone? = !principals.phone.nil?
|
|
1300
|
+
|
|
1301
|
+
# Whether the phone column is NULL-able. An optional phone obviously is; a
|
|
1302
|
+
# *required* one is too under --guestable, because guests carry a nil number (no
|
|
1303
|
+
# synthesized numbers — that would risk texting a real stranger), so presence is
|
|
1304
|
+
# enforced by the model validation's `unless: :guest?` rather than by NOT NULL.
|
|
1305
|
+
def phone_column_nullable?
|
|
1306
|
+
principals.phone&.then { |p| p.optional? || guestable? } || false
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
# The sign-in form's identifier field name. One login key → its own column
|
|
1310
|
+
# ("email"). Several → a single field-agnostic "login" the controller
|
|
1311
|
+
# dispatches by shape ("@" means email).
|
|
1312
|
+
def sign_in_field = multi_login? ? "login" : login_field
|
|
1313
|
+
|
|
1314
|
+
# The redirect hint param that carries the just-entered identifier back to the
|
|
1315
|
+
# form. Single-key builds name the field ("email_hint"); multi-key builds use
|
|
1316
|
+
# the field-agnostic "login_hint".
|
|
1317
|
+
def login_hint_param = multi_login? ? "login_hint" : "#{login_field}_hint"
|
|
1318
|
+
|
|
1319
|
+
# A no-password lookup by whichever login key was submitted, as a Ruby
|
|
1320
|
+
# expression for the templates. Single-key builds look the one column up
|
|
1321
|
+
# directly; multi-key builds fold the "@"-dispatch into the model's
|
|
1322
|
+
# find_by_login (generated only then, in Authenticatable).
|
|
1323
|
+
def find_by_login_expr(identifier_expr)
|
|
1324
|
+
if multi_login?
|
|
1325
|
+
"#{identity.class_name}.find_by_login(#{identifier_expr})"
|
|
1326
|
+
else
|
|
1327
|
+
"#{identity.class_name}.find_by(#{login_field}: #{identifier_expr})"
|
|
1328
|
+
end
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
# Copy for a failed sign-in lookup — names the login key(s), stays paranoid.
|
|
1332
|
+
# Punctuation is added by each caller (HTML ends the sentence with a period,
|
|
1333
|
+
# the JSON API omits it). A multi-key build names both keys ("username/email").
|
|
1334
|
+
def incorrect_login_message
|
|
1335
|
+
keys = multi_login? ? login_principals.map(&:column).join("/") : login_field
|
|
1336
|
+
"That #{keys} or password is incorrect"
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
# The two halves of the sign-in miss branch, which is now the only place a deadbolt
|
|
1340
|
+
# is spoken about: authenticate_with_password answers nil for a deadbolted account
|
|
1341
|
+
# exactly as it does for a wrong password, so a coy build has nothing to say and
|
|
1342
|
+
# nothing to decide.
|
|
1343
|
+
#
|
|
1344
|
+
# A --no-coy build is honest about account existence, so it keeps the record it
|
|
1345
|
+
# just looked up in order to tell a deadbolted visitor to come back later. The
|
|
1346
|
+
# window is real (config.deadbolt.duration), so "try again later" is true rather
|
|
1347
|
+
# than a brush-off, and it never says "deadbolted" — that is our word, not theirs.
|
|
1348
|
+
def failed_sign_in_lookup
|
|
1349
|
+
return deferred_call(identity.class_name, :register_failed_attempt, "#{login_identifier_expr}#{".to_s" unless multi_login?}") if coy?
|
|
1350
|
+
|
|
1351
|
+
"other = #{identity.class_name}.register_failed_attempt(#{login_identifier_expr})"
|
|
1352
|
+
end
|
|
1353
|
+
|
|
1354
|
+
# Work that happens only for some accounts leaves the request in a coy build, so
|
|
1355
|
+
# response timing can't tell which accounts exist.
|
|
1356
|
+
def deferred_call(receiver, method, *args)
|
|
1357
|
+
return "AuthnzElevenDeferredJob.perform_later(#{[receiver, ":#{method}", *args].join(", ")})" if coy?
|
|
1358
|
+
|
|
1359
|
+
"#{receiver}.#{method}#{"(#{args.join(", ")})" if args.any?}"
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
# +period+ follows incorrect_login_message's rule: HTML ends the sentence, the
|
|
1363
|
+
# JSON API doesn't.
|
|
1364
|
+
def failed_sign_in_alert(period: true)
|
|
1365
|
+
stop = "." if period
|
|
1366
|
+
return %("#{incorrect_login_message}#{stop}") if coy?
|
|
1367
|
+
|
|
1368
|
+
%(other&.deadbolted? ? "Too many failed attempts. Please try again later#{stop}" : "#{incorrect_login_message}#{stop}")
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1371
|
+
# The expression each sign-in shape holds its submitted login key in.
|
|
1372
|
+
def login_identifier_expr = multi_login? ? "identifier" : "params[:#{login_field}]"
|
|
1373
|
+
|
|
1374
|
+
def login_principals = principals.login_principals
|
|
1375
|
+
|
|
1376
|
+
# Whether a record can be saved with every login key blank — which is an account
|
|
1377
|
+
# nobody can ever sign in to again. It saves cleanly and even signs in once, and
|
|
1378
|
+
# is only discovered later at a sign-in form with nothing to type into it.
|
|
1379
|
+
#
|
|
1380
|
+
# No single presence rule can prevent it: each key is legitimately optional on its
|
|
1381
|
+
# own, and what must hold is that *some* one of them is filled. So the models get
|
|
1382
|
+
# a record-level check instead (the Principals concern's #at_least_one_login_key),
|
|
1383
|
+
# and only where it can fire — a build with any required login key already has its
|
|
1384
|
+
# guarantee, and a second rule there would be dead code.
|
|
1385
|
+
#
|
|
1386
|
+
# validate_email_none! is the same question one step earlier: it refuses a build
|
|
1387
|
+
# whose email COLUMN is missing with nothing to replace it, this catches the row
|
|
1388
|
+
# whose optional columns all came back blank.
|
|
1389
|
+
def login_key_may_be_missing? = login_principals.all?(&:optional?)
|
|
1390
|
+
|
|
1391
|
+
# Whether every account must hold a channel (--contactable, on by default).
|
|
1392
|
+
def contactable? = options.contactable?
|
|
1393
|
+
|
|
1394
|
+
# Whether that promise needs a record-level rule, or is already guaranteed.
|
|
1395
|
+
#
|
|
1396
|
+
# Emitted only where it can fire, so it is never dead code restating a rule
|
|
1397
|
+
# something else already enforces. Four ways it can't fire:
|
|
1398
|
+
#
|
|
1399
|
+
# * --no-contactable: no promise to keep
|
|
1400
|
+
# * fewer than two channels: validate_contactable! has already refused the
|
|
1401
|
+
# ambiguous shape, so a lone channel here is required and says so itself
|
|
1402
|
+
# * a required channel: its own presence rule IS the guarantee
|
|
1403
|
+
# * every login key is a channel: at_least_one_login_key already emits this
|
|
1404
|
+
# exact condition, character for character
|
|
1405
|
+
#
|
|
1406
|
+
# What's left is a non-channel login key (a username) beside two optional
|
|
1407
|
+
# channels — the account can sign in holding neither, and nothing else notices.
|
|
1408
|
+
def contactable_validation?
|
|
1409
|
+
return false unless contactable?
|
|
1410
|
+
|
|
1411
|
+
channels = principals.select(&:channel?)
|
|
1412
|
+
channels.size > 1 && channels.none?(&:required?) && !login_principals.all?(&:channel?)
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
# "They submitted no channel at all", as a Ruby condition. Only asked in builds
|
|
1416
|
+
# where that is possible AND survivable, so the branch it guards is never dead.
|
|
1417
|
+
def channels_blank_condition
|
|
1418
|
+
principals.select(&:channel?).map { |principal| "@registration.#{principal.column}.blank?" }.join(" && ")
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
# Natural-language list of the login keys for user-facing copy ("username, email,
|
|
1422
|
+
# or phone"). A single-key build reads as just the column ("email").
|
|
1423
|
+
def login_keys_phrase = or_phrase(login_principals.map(&:column))
|
|
1424
|
+
|
|
1425
|
+
# "a", "a or b", "a, b or c". Three callers below want this and none of them
|
|
1426
|
+
# want it to disagree with the others.
|
|
1427
|
+
def or_phrase(items)
|
|
1428
|
+
return items.first if items.one?
|
|
1429
|
+
|
|
1430
|
+
"#{items[0..-2].join(", ")} or #{items.last}"
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
# The same list with articles, for a sentence addressed to somebody filling in a
|
|
1434
|
+
# form ("Enter an email or a phone number."). login_keys_phrase reads as a label;
|
|
1435
|
+
# this reads as an instruction.
|
|
1436
|
+
ARTICLED_PRINCIPAL_NOUNS = {
|
|
1437
|
+
email: "an email address", phone: "a phone number", username: "a username"
|
|
1438
|
+
}.freeze
|
|
1439
|
+
|
|
1440
|
+
def login_keys_article_phrase
|
|
1441
|
+
or_phrase(login_principals.map { |principal| ARTICLED_PRINCIPAL_NOUNS.fetch(principal.type) })
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
# The same, for the channels — what must_be_contactable tells somebody to add.
|
|
1445
|
+
def channel_keys_article_phrase
|
|
1446
|
+
or_phrase(principals.select(&:channel?).map { |principal| ARTICLED_PRINCIPAL_NOUNS.fetch(principal.type) })
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
# The argument list for a sign-up's params.permit — the principal columns this
|
|
1450
|
+
# form may submit, plus whatever else the call site needs, as one "permit"
|
|
1451
|
+
# fragment (e.g. ":email, :password, :password_confirmation").
|
|
1452
|
+
#
|
|
1453
|
+
# A single-channel invite-only build pins that channel to the invitation
|
|
1454
|
+
# (merged server-side by #user_params / #registration_params, never permitted).
|
|
1455
|
+
# A multi-channel build permits both and lets the final merge replace only the
|
|
1456
|
+
# addressed one. A username is still collected from the form.
|
|
1457
|
+
#
|
|
1458
|
+
# The extras are arguments rather than something a call site concatenates,
|
|
1459
|
+
# because the principal list can be *empty*: an invite-only build whose only
|
|
1460
|
+
# principal was the address has nothing left to permit. Joining here is what
|
|
1461
|
+
# renders that as `params.permit(:password, :password_confirmation)`, or a bare
|
|
1462
|
+
# `params.permit()`, rather than a stray leading comma.
|
|
1463
|
+
def permit_list(*extra)
|
|
1464
|
+
cols = principals.map(&:column)
|
|
1465
|
+
cols -= [principals.channels.first.column] if invitation_pins_only_channel?
|
|
1466
|
+
(cols + extra.map(&:to_s)).map { |c| ":#{c}" }.join(", ")
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1469
|
+
# The whole right-hand side of a sign-up's params method, so the emitted method
|
|
1470
|
+
# is one line whether or not there's an invitation to pin the address to.
|
|
1471
|
+
def sign_up_params_expression
|
|
1472
|
+
extra = password_on_sign_up_form? ? %w[password password_confirmation] : []
|
|
1473
|
+
permitted = "params.permit(#{permit_list(*extra)})"
|
|
1474
|
+
invitable? ? "with_invitation(#{permitted})" : permitted
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# The whole pair, not just the key: a dynamic key needs a hash rocket, a
|
|
1478
|
+
# literal one must not have it (Style/HashSyntax).
|
|
1479
|
+
def invitation_channel_pair
|
|
1480
|
+
return "invitation.sent_to_channel => invitation.sent_to" if multi_channel_invitable?
|
|
1481
|
+
|
|
1482
|
+
"#{principals.channels.first.column}: invitation.sent_to"
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
# An app with none of these leaves a locked-out user no way back in. Used only
|
|
1486
|
+
# to warn at install time; enforcing nothing (it's a legitimate choice).
|
|
1487
|
+
# recoverable? implies password?, so this can't advertise a password
|
|
1488
|
+
# reset for an app that has no password. --sms-code counts: someone who forgot
|
|
1489
|
+
# their password can still get in with a texted code.
|
|
1490
|
+
def recovery_path? = recoverable? || magic_link? || omniauth? || sms_code?
|
|
1491
|
+
|
|
1492
|
+
# A legal typed submission needs pre-account enrollment unless the form itself
|
|
1493
|
+
# guarantees a password or a required channel becomes a usable configured door.
|
|
1494
|
+
# This is deliberately a build capability; PendingRegistration#next_step answers
|
|
1495
|
+
# the corresponding question for one row.
|
|
1496
|
+
def typed_registration_guarantees_sign_in_method?
|
|
1497
|
+
password_required? ||
|
|
1498
|
+
(magic_link? && principals.email&.required?) ||
|
|
1499
|
+
(sms_code? && principals.phone&.required?)
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
def credential_staged_registration?
|
|
1503
|
+
typed_registration? && !typed_registration_guarantees_sign_in_method?
|
|
1504
|
+
end
|
|
1505
|
+
|
|
1506
|
+
def pre_account_password_enrollment? = credential_staged_registration? && password?
|
|
1507
|
+
def pre_account_passkey_enrollment? = credential_staged_registration? && passkey?
|
|
1508
|
+
|
|
1509
|
+
# What the gate offers, which is not the same as what could clear it.
|
|
1510
|
+
#
|
|
1511
|
+
# A provider is deliberately absent. A provider sign-up arrives already holding
|
|
1512
|
+
# :omniauth and is never staged, so everybody here took the typed route and
|
|
1513
|
+
# declined the button on the sign-up form. Offering it back asks a question they
|
|
1514
|
+
# answered on the previous page. It returns below as the fallback for the build
|
|
1515
|
+
# where it is the only way out.
|
|
1516
|
+
def registration_credential_options
|
|
1517
|
+
[(:password if pre_account_password_enrollment?),
|
|
1518
|
+
(:passkey if pre_account_passkey_enrollment?)].compact
|
|
1519
|
+
end
|
|
1520
|
+
|
|
1521
|
+
# The build with nothing else to enroll. Here the provider button is not a
|
|
1522
|
+
# question already answered, it is the only door this sign-up can reach.
|
|
1523
|
+
def pre_account_provider_enrollment?
|
|
1524
|
+
credential_staged_registration? && omniauth? && registration_credential_options.empty?
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
# Whether an invitation addressed to this channel becomes an account at the
|
|
1528
|
+
# sign-up POST. The invitation proves its own channel, but a second channel the
|
|
1529
|
+
# build requires is still unproved and holds the registration open — as does a
|
|
1530
|
+
# build whose form leaves the row with no way to sign in.
|
|
1531
|
+
def email_invitation_completes_registration? = invited_registration_completes?(:email)
|
|
1532
|
+
def phone_invitation_completes_registration? = invited_registration_completes?(:phone)
|
|
1533
|
+
|
|
1534
|
+
def invited_registration_completes?(channel)
|
|
1535
|
+
return true unless typed_staged_registration?
|
|
1536
|
+
return false unless typed_registration_guarantees_sign_in_method? || channel_door?(channel)
|
|
1537
|
+
|
|
1538
|
+
other = principals.select(&:channel?).find { |principal| principal.type != channel }
|
|
1539
|
+
!(other&.required? && channel_verifiable?(other))
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1542
|
+
def channel_door?(channel) = channel == :email ? magic_link? : sms_code?
|
|
1543
|
+
|
|
1544
|
+
# The emitted sign-up fixture fills every channel, so once they are proven any
|
|
1545
|
+
# channel-backed door signs it in, whatever the build guarantees.
|
|
1546
|
+
def sign_up_fixture_can_sign_in? = password_required? || magic_link? || sms_code?
|
|
1547
|
+
|
|
1548
|
+
# An invite-only sign-up needs an accepted invitation in the session before it posts.
|
|
1549
|
+
def accept_test_invitation(sent_to)
|
|
1550
|
+
path = identity.nested_helper("accept_invitation_path")
|
|
1551
|
+
[
|
|
1552
|
+
%(invitation = #{identity.invitation_class}.create!(sent_to: #{sent_to}, inviter: #{identity.plural}(:alice))),
|
|
1553
|
+
%(get #{path}(token: invitation.generate_token_for(:invitation)))
|
|
1554
|
+
].join("\n ")
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1557
|
+
# An invite-only staged row belongs to its invitation, so a test that builds one
|
|
1558
|
+
# outside the sign-up flow has to supply one too. `save(validate: false)` skips
|
|
1559
|
+
# the association's presence rule but not the column's NOT NULL.
|
|
1560
|
+
def staged_registration_invitation(address)
|
|
1561
|
+
return "" unless invite_only?
|
|
1562
|
+
|
|
1563
|
+
channel = (%(, sent_to_channel: "email") if multi_channel_invitable?)
|
|
1564
|
+
%(, invitation: #{identity.invitation_class}.create!(sent_to: #{address}#{channel}, inviter: @user))
|
|
1565
|
+
end
|
|
1566
|
+
|
|
1567
|
+
# One step of the staged sign-up, wherever it is reached from. Emitted once, into
|
|
1568
|
+
# the Registering concern every registration controller includes.
|
|
1569
|
+
def advance_body
|
|
1570
|
+
lines = [" def advance(registration)", " case registration.next_step"]
|
|
1571
|
+
if omniauth_registration?
|
|
1572
|
+
lines << " when :complete_profile"
|
|
1573
|
+
lines << " redirect_to new_#{identity.route_scope}_omniauth_registration_path"
|
|
1574
|
+
end
|
|
1575
|
+
if email_gated_registration?
|
|
1576
|
+
lines << " when :verify_email"
|
|
1577
|
+
lines << " #{deferred_call("registration", :request_email_verification)}"
|
|
1578
|
+
lines << " redirect_to #{identity.route_scope}_new_verify_email_path"
|
|
1579
|
+
end
|
|
1580
|
+
if phone_gated_registration?
|
|
1581
|
+
lines << " when :verify_phone"
|
|
1582
|
+
lines << " #{deferred_call("registration", :request_phone_verification)}"
|
|
1583
|
+
lines << " redirect_to #{identity.route_scope}_verify_phone_path"
|
|
1584
|
+
end
|
|
1585
|
+
if credential_staged_registration?
|
|
1586
|
+
lines << " when :enroll_credential"
|
|
1587
|
+
lines << %( redirect_to #{credential_enrollment_path}, notice: "#{credential_enrollment_alert}")
|
|
1588
|
+
end
|
|
1589
|
+
lines += [" when :complete", " finish_registration(registration.complete)", " end", " end"]
|
|
1590
|
+
lines.join("\n")
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
# Only worth a page of its own when there is an actual choice to put on it. One
|
|
1594
|
+
# option means one destination, and a chooser above a single button is worse
|
|
1595
|
+
# than the button. The provider fallback needs the page too: a button is all it
|
|
1596
|
+
# has, and there is nowhere else to put one.
|
|
1597
|
+
def registration_credential_chooser? = registration_credential_options.size > 1
|
|
1598
|
+
def registration_credential_page? = registration_credential_chooser? || pre_account_provider_enrollment?
|
|
1599
|
+
|
|
1600
|
+
def credential_enrollment_page_path(option)
|
|
1601
|
+
case option
|
|
1602
|
+
when :password then "#{identity.route_scope}_new_registration_password_path"
|
|
1603
|
+
when :passkey then "#{identity.route_scope}_new_registration_passkey_path"
|
|
1604
|
+
end
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1607
|
+
def credential_enrollment_path
|
|
1608
|
+
return "#{identity.route_scope}_finish_setup_path" if registration_credential_page?
|
|
1609
|
+
|
|
1610
|
+
credential_enrollment_page_path(registration_credential_options.first) ||
|
|
1611
|
+
"#{identity.route_scope}_sign_up_path"
|
|
1612
|
+
end
|
|
1613
|
+
|
|
1614
|
+
def credential_enrollment_alert
|
|
1615
|
+
return "Choose how you'll sign in to finish setting up your account." if registration_credential_chooser?
|
|
1616
|
+
|
|
1617
|
+
case registration_credential_options.first
|
|
1618
|
+
when :passkey then "Create a passkey to finish setting up your account."
|
|
1619
|
+
when :password then "Choose a password to finish setting up your account."
|
|
1620
|
+
else "Connect an account to finish setting up your account."
|
|
1621
|
+
end
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
# The before_actions that hold a signed-in account at a page until it fixes
|
|
1625
|
+
# something, in the order they run: the callback, the predicate that lifts it,
|
|
1626
|
+
# the page it holds you at, and what it says there.
|
|
1627
|
+
def impediments
|
|
1628
|
+
[
|
|
1629
|
+
[admin_mfa_gate?, "require_admin_mfa", "admin_mfa_enrolled?", admin_mfa_hold_path,
|
|
1630
|
+
"#{totp? ? "Set up a second factor" : "Enroll a security key"} to continue."],
|
|
1631
|
+
[max_sessions_prompt?, "require_session_within_limit", "within_session_limit?",
|
|
1632
|
+
"#{identity.settings_helper_prefix}_sessions_path", "Sign out of another session to continue."],
|
|
1633
|
+
[password_rotatable?, "require_fresh_password", "password_fresh?",
|
|
1634
|
+
"edit_#{identity.settings_helper_prefix}_password_path", "Your password has expired. Choose a new one to continue."],
|
|
1635
|
+
[teams?, "require_team", "on_a_team?", identity.nested_helper("teams_path"), "Create or join a team to continue."]
|
|
1636
|
+
].select(&:first).map { |_in_this_build, *impediment| impediment }
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1639
|
+
def impeded? = impediments.any?
|
|
1640
|
+
|
|
1641
|
+
def impediment_callbacks = impediments.map { |callback, *| ":#{callback}" }.join(", ")
|
|
1642
|
+
|
|
1643
|
+
def impediment_conditions = impersonatable? ? "if: :authenticated?, unless: :impersonating?" : "if: :authenticated?"
|
|
1644
|
+
|
|
1645
|
+
def pending_registration_session_key = "pending_#{identity.singular}_registration"
|
|
1646
|
+
|
|
1647
|
+
def interrupted_destination_key = session_key(:interrupted_destination)
|
|
1648
|
+
|
|
1649
|
+
# Every identity writes into the one Rails session, so a namespaced identity's keys
|
|
1650
|
+
# carry its name or two identities would read each other's.
|
|
1651
|
+
def session_key(name) = identity.namespaced? ? "#{identity.singular}_#{name}" : name.to_s
|
|
1652
|
+
|
|
1653
|
+
# Which second factors an account may enroll, from --second-factor=totp,webauthn.
|
|
1654
|
+
# Empty when the flag is absent, so second_factor? is "this build has any".
|
|
1655
|
+
def second_factors
|
|
1656
|
+
@second_factors ||= options.second_factor.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
def second_factor? = second_factors.any?
|
|
1660
|
+
def totp? = second_factors.include?(:totp)
|
|
1661
|
+
def security_keys? = second_factors.include?(:webauthn)
|
|
1662
|
+
def sms_second_factor? = second_factors.include?(:sms)
|
|
1663
|
+
|
|
1664
|
+
# More than one enrolled means a sign-in has to choose where to land, and each
|
|
1665
|
+
# challenge page has somewhere to link. Neither question exists with one factor,
|
|
1666
|
+
# so the chooser and the cross-links are generated only here.
|
|
1667
|
+
def multiple_second_factors? = second_factors.size > 1
|
|
1668
|
+
|
|
1669
|
+
# The narrower pair: emitted tests that drive an authenticator and a security key
|
|
1670
|
+
# against each other need both to exist.
|
|
1671
|
+
def both_second_factors? = totp? && security_keys?
|
|
1672
|
+
|
|
1673
|
+
# How an account is asked whether it holds each factor, strongest first — the
|
|
1674
|
+
# order the challenge chooser and second_factor_enrolled? both read.
|
|
1675
|
+
def second_factor_enrolled_clauses
|
|
1676
|
+
clauses = []
|
|
1677
|
+
clauses << "#{second_factor_keys_expr}.any?" if security_keys?
|
|
1678
|
+
clauses << "totp_enrolled_at?" if totp?
|
|
1679
|
+
clauses << "sms_enrolled_at?" if sms_second_factor?
|
|
1680
|
+
clauses
|
|
1681
|
+
end
|
|
1682
|
+
|
|
1683
|
+
def second_factor_challenge_path_for(factor) = "new_#{identity.route_scope}_mfa_challenge_#{factor}_path"
|
|
1684
|
+
|
|
1685
|
+
# The challenge chooser's body. The strongest factor the account actually holds
|
|
1686
|
+
# wins; the weakest is the fallthrough, so the chain always lands somewhere. With
|
|
1687
|
+
# one factor this is that factor's path and nothing else, which is what
|
|
1688
|
+
# start_new_session_for redirects to directly.
|
|
1689
|
+
def second_factor_challenge_lines
|
|
1690
|
+
branches = []
|
|
1691
|
+
branches << ["#{second_factor_keys_expr("user")}.any?", "security_keys"] if security_keys?
|
|
1692
|
+
branches << ["user.totp_enrolled_at?", "totp"] if totp?
|
|
1693
|
+
branches << ["user.sms_enrolled_at?", "sms"] if sms_second_factor?
|
|
1694
|
+
|
|
1695
|
+
*guarded, (_, fallthrough) = branches
|
|
1696
|
+
guarded.map { |clause, factor| "return #{second_factor_challenge_path_for(factor)} if #{clause}" } <<
|
|
1697
|
+
second_factor_challenge_path_for(fallthrough)
|
|
1698
|
+
end
|
|
1699
|
+
|
|
1700
|
+
# The "use something else instead" links a challenge page offers: every other
|
|
1701
|
+
# factor this build has, each shown only when the account actually holds it.
|
|
1702
|
+
# Empty with one factor, which is how the links disappear from those builds.
|
|
1703
|
+
def second_factor_alternatives(except)
|
|
1704
|
+
factors = []
|
|
1705
|
+
factors << ["security_keys", "#{second_factor_keys_expr("@user")}.any?", "Use a security key instead"] if security_keys?
|
|
1706
|
+
factors << ["totp", "@user.totp_enrolled_at?", "Use your authenticator app instead"] if totp?
|
|
1707
|
+
factors << ["sms", "@user.sms_enrolled_at?", "Get a code by text instead"] if sms_second_factor?
|
|
1708
|
+
|
|
1709
|
+
factors.reject { |factor, _, _| factor == except.to_s }
|
|
1710
|
+
.map { |factor, clause, label| [clause, label, second_factor_challenge_path_for(factor)] }
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
# --second-factor=webauthn is security keys as a *second* factor; --passkey is
|
|
1714
|
+
# passkeys as a *primary* passwordless door (a strategy of its own, no second
|
|
1715
|
+
# factor). Both are WebAuthn credentials stored in the same table, so
|
|
1716
|
+
# webauthn_credentials? gates the shared machinery (gem, RP initializer,
|
|
1717
|
+
# webauthn_credentials table + model, webauthn_handle, the JS controller).
|
|
1718
|
+
def passkey? = options.passkey?
|
|
1719
|
+
def webauthn_credentials? = security_keys? || passkey?
|
|
1720
|
+
|
|
1721
|
+
# Only when a build has both roles does the shared table need to tell them
|
|
1722
|
+
# apart (so the two management UIs don't list each other's credentials); the
|
|
1723
|
+
# authentication_factor column and the model scopes exist only in that case.
|
|
1724
|
+
def passkey_and_security_keys? = passkey? && security_keys?
|
|
1725
|
+
|
|
1726
|
+
# How the templates ask for an account's second-factor keys: plain in a build
|
|
1727
|
+
# where every credential is one, scoped where passkeys share the table. Pass no
|
|
1728
|
+
# receiver inside the model itself.
|
|
1729
|
+
def second_factor_keys_expr(receiver = nil)
|
|
1730
|
+
"#{receiver}#{"." if receiver}webauthn_credentials#{".second_factors" if passkey_and_security_keys?}"
|
|
1731
|
+
end
|
|
1732
|
+
|
|
1733
|
+
# The role a credential is created with, where the build has both.
|
|
1734
|
+
def factor_attr(role) = (", authentication_factor: :#{role}" if passkey_and_security_keys?)
|
|
1735
|
+
|
|
1736
|
+
# The sign-in doors a user's enrolled second factor is required at by default:
|
|
1737
|
+
# every knowledge/possession door this build actually has. A passkey door
|
|
1738
|
+
# (when it exists) is deliberately omitted — a passkey is itself a strong,
|
|
1739
|
+
# phishing-resistant factor, so it clears the bar on its own and is never
|
|
1740
|
+
# stepped up. Seeds config.second_factor.needed_after, which the app owner can
|
|
1741
|
+
# edit. Provider doors are never seeded: a provider names itself (:google, not
|
|
1742
|
+
# :omniauth) and only the app knows which ones it has configured.
|
|
1743
|
+
def default_needed_after
|
|
1744
|
+
strategies = []
|
|
1745
|
+
strategies << :password if password?
|
|
1746
|
+
strategies << :sms_code if sms_code?
|
|
1747
|
+
strategies
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1750
|
+
# ---- sessions -------------------------------------------------------------
|
|
1751
|
+
|
|
1752
|
+
# Emit a sign-in call. Every one names the door it came through, so
|
|
1753
|
+
# config.second_factor.needed_after describes the whole policy and no site can
|
|
1754
|
+
# sidestep it, and sessions.via records it.
|
|
1755
|
+
# +subject+ is the local holding the user ("user", "@user"). +via+ is a Symbol
|
|
1756
|
+
# for a fixed door, or a String of Ruby for one only the request knows (the
|
|
1757
|
+
# omniauth provider).
|
|
1758
|
+
#
|
|
1759
|
+
# With two-factor the call can divert to the MFA challenge and answer false, so
|
|
1760
|
+
# a door must `return unless` it. Pass may_challenge: false where that can't
|
|
1761
|
+
# happen — an account created moments ago has no factor to be asked for — and
|
|
1762
|
+
# the guard is left off rather than emitted dead.
|
|
1763
|
+
def sign_in_call(subject, via:, remember_expr: nil, may_challenge: true)
|
|
1764
|
+
remember = ", remember: #{remember_expr}" if rememberable? && remember_expr
|
|
1765
|
+
door = via.is_a?(Symbol) ? ":#{via}" : via
|
|
1766
|
+
call = "start_new_session_for(#{subject}, via: #{door}#{remember})"
|
|
1767
|
+
may_challenge && second_factor? ? "return unless #{call}" : call
|
|
1768
|
+
end
|
|
1769
|
+
|
|
1770
|
+
def trackable? = options.trackable?
|
|
1771
|
+
def last_seenable? = options.last_seenable?
|
|
1772
|
+
def security_notifications? = options.security_notifications?
|
|
1773
|
+
def api_tokens? = options.api_tokens?
|
|
1774
|
+
|
|
1775
|
+
# "Remember me" is a cookie-lifetime policy on the browser session cookie, and
|
|
1776
|
+
# the idle timeout resolves the DB session per request.
|
|
1777
|
+
def rememberable? = options.rememberable?
|
|
1778
|
+
def timeoutable? = options.timeoutable?
|
|
1779
|
+
|
|
1780
|
+
# A concurrent-session cap enforced at the one choke point every door funnels
|
|
1781
|
+
# through (start_new_session_for).
|
|
1782
|
+
def max_sessionable? = options.max_sessionable.present?
|
|
1783
|
+
|
|
1784
|
+
# Which over-limit strategy the flag selected. "evict" (default) silently signs
|
|
1785
|
+
# out the least-recently-active session at sign-in; "prompt" holds the new
|
|
1786
|
+
# sign-in at a completion gate until the user trims a session themselves.
|
|
1787
|
+
def max_sessions_evict? = max_sessionable? && options.max_sessionable == "evict"
|
|
1788
|
+
def max_sessions_prompt? = max_sessionable? && options.max_sessionable == "prompt"
|
|
1789
|
+
|
|
1790
|
+
# Whether this build has a typed sign-up FORM — not whether accounts can be
|
|
1791
|
+
# created. The omniauth callback registers with no form at all, which is why
|
|
1792
|
+
# pending_registration? asks that separately.
|
|
1793
|
+
def typed_registration? = options[:registration] != "closed" && !social_login_only?
|
|
1794
|
+
|
|
1795
|
+
# Whether to advertise a *public* sign-up path in the UI. Invite-only keeps the
|
|
1796
|
+
# registration controller and route (reached only via an invitation link), but
|
|
1797
|
+
# there is no open sign-up — so the login page must not link to one.
|
|
1798
|
+
def public_registration? = typed_registration? && !invite_only?
|
|
1799
|
+
|
|
1800
|
+
# has_secure_password is opt-in. Any feature that is meaningless without a
|
|
1801
|
+
# password implies it — you can't reset or breach-check a password that doesn't
|
|
1802
|
+
# exist — so asking for one turns --password on (same shape as adminable? below).
|
|
1803
|
+
# Two flags are deliberately NOT here. sudoable? re-proves whatever factor the
|
|
1804
|
+
# user actually holds, so it never assumes a password exists. deadboltable?
|
|
1805
|
+
# needs one, but asking for a brute-force defence is not asking for the thing
|
|
1806
|
+
# being defended — validate_deadboltable! refuses that rather than invent a door.
|
|
1807
|
+
def password? = options.password? || recoverable? || pwned? || strong_passwords? || password_rotatable? || password_historical?
|
|
1808
|
+
|
|
1809
|
+
# Password reset ("forgot password"). Opt-in, and implies --password
|
|
1810
|
+
# (see above). Orthogonal to --magic-link: reset recovers a *passworded*
|
|
1811
|
+
# account, while magic-link is an additive way in that needs no password.
|
|
1812
|
+
def recoverable? = options.recoverable?
|
|
1813
|
+
|
|
1814
|
+
# Password expiry / rotation. Opt-in, implies --password (you can't
|
|
1815
|
+
# expire a password that doesn't exist). The maximum age itself lives in the
|
|
1816
|
+
# initializer (config.password.maximum_age, default 90.days; nil disables),
|
|
1817
|
+
# tunable without regenerating — the same config-not-flag shape as
|
|
1818
|
+
# timeoutable?'s idle_timeout and the session cap.
|
|
1819
|
+
def password_rotatable? = options.password_rotatable?
|
|
1820
|
+
|
|
1821
|
+
# Password-reuse history. Opt-in, implies --password. The depth lives in the
|
|
1822
|
+
# initializer (config.password.history_depth, default 5; nil keeps archiving
|
|
1823
|
+
# but stops rejecting).
|
|
1824
|
+
def password_historical? = options.password_historical?
|
|
1825
|
+
|
|
1826
|
+
# Whether a channel gets proved, asked one channel at a time. --no-verifiable is
|
|
1827
|
+
# a single opt-out that drops verification everywhere, but these predicates stay
|
|
1828
|
+
# per-channel so every template asks "does *this* channel verify?" rather than "is
|
|
1829
|
+
# the flag set?" — which is what would let a per-channel opt-out arrive without
|
|
1830
|
+
# touching a template.
|
|
1831
|
+
#
|
|
1832
|
+
# Each is keyed to its own principal, so a build without that principal forces it
|
|
1833
|
+
# false and emits none of its flow.
|
|
1834
|
+
#
|
|
1835
|
+
# Email: a signed link, clicked from the inbox. Dropping it takes the staged sign-up
|
|
1836
|
+
# and staged change, their tokens, mailers, controllers, routes and views; sign-up
|
|
1837
|
+
# then creates the account straight away and settings writes the column directly.
|
|
1838
|
+
def email_verifiable? = options.verifiable? && !principals.email.nil?
|
|
1839
|
+
|
|
1840
|
+
# Phone: a six-digit one-time code, typed into a form. Dropping it takes the staged
|
|
1841
|
+
# sign-up and staged change and their controllers, routes and views, leaving the
|
|
1842
|
+
# plain phone column and its login-key dispatch untouched. It is also what pulls in
|
|
1843
|
+
# the whole SMS delivery seam (see sms?), so a build that wants a phone column
|
|
1844
|
+
# without an SMS vendor is the case --no-verifiable serves.
|
|
1845
|
+
def phone_verifiable? = options.verifiable? && !principals.phone.nil?
|
|
1846
|
+
|
|
1847
|
+
def channel_verifiable?(principal) = principal.type == :email ? email_verifiable? : phone_verifiable?
|
|
1848
|
+
|
|
1849
|
+
# --encrypted-pii is a single boolean because invitations.sent_to holds either
|
|
1850
|
+
# channel in one column, so a per-channel split has no coherent answer there. The
|
|
1851
|
+
# predicates stay per-channel anyway, so every template asks about a channel rather
|
|
1852
|
+
# than about the flag.
|
|
1853
|
+
def email_encrypted? = options.encrypted_pii? && !principals.email.nil?
|
|
1854
|
+
def phone_encrypted? = options.encrypted_pii? && !principals.phone.nil?
|
|
1855
|
+
|
|
1856
|
+
# A TOTP secret is the one reversible credential in the schema — it can't be digested,
|
|
1857
|
+
# because verifying a code means recomputing it. So it is always encrypted, with no
|
|
1858
|
+
# flag: plaintext there means a database dump is a set of working second factors.
|
|
1859
|
+
# Nothing queries it, so it takes the random-IV mode and rotates freely.
|
|
1860
|
+
def encryption? = encrypted_pii? || totp?
|
|
1861
|
+
|
|
1862
|
+
def encrypted_pii? = options.encrypted_pii?
|
|
1863
|
+
|
|
1864
|
+
# Whether this build generates a way for the account holder to change the value
|
|
1865
|
+
# themselves: the /settings resource, its form, and — where the channel is
|
|
1866
|
+
# verified — the whole staged-change apparatus behind it (the pending_ column,
|
|
1867
|
+
# its token and mailer, the confirm and resend endpoints). ",permanent" is the
|
|
1868
|
+
# flag that turns these off.
|
|
1869
|
+
def email_changeable? = !principals.email.nil? && principals.email.changeable?
|
|
1870
|
+
def phone_changeable? = !principals.phone.nil? && principals.phone.changeable?
|
|
1871
|
+
|
|
1872
|
+
# A staged change, proved before it lands. Distinct from email_gated_registration?,
|
|
1873
|
+
# which proves the value an account is BORN with — a permanent build keeps that and
|
|
1874
|
+
# drops this.
|
|
1875
|
+
def email_change_verification? = email_verifiable? && email_changeable?
|
|
1876
|
+
def phone_change_verification? = phone_verifiable? && phone_changeable?
|
|
1877
|
+
|
|
1878
|
+
# Cache-backed six-digit challenges. A signed-link phone invitation needs the
|
|
1879
|
+
# provider and delivery job but none of this code-entry machinery.
|
|
1880
|
+
def sms? = phone_verifiable? || sms_code? || sms_second_factor?
|
|
1881
|
+
|
|
1882
|
+
# The app-global SMS provider seam, generated for challenges or invitations.
|
|
1883
|
+
def sms_delivery? = sms? || phone_invitable?
|
|
1884
|
+
|
|
1885
|
+
# Proof staging and method staging are independent capabilities. A provider
|
|
1886
|
+
# sign-up that still owes profile fields is the third reason the aggregate exists.
|
|
1887
|
+
def channel_staged_registration?
|
|
1888
|
+
typed_registration? && (email_verifiable? || phone_verifiable?)
|
|
1889
|
+
end
|
|
1890
|
+
|
|
1891
|
+
# Whether the typed sign-up form stages a row rather than creating the account.
|
|
1892
|
+
def typed_staged_registration? = typed_registration? && (channel_staged_registration? || credential_staged_registration?)
|
|
1893
|
+
|
|
1894
|
+
def pending_registration_model? = typed_staged_registration? || omniauth_registration?
|
|
1895
|
+
|
|
1896
|
+
# Where the row carries a provider login: either the provider sign-up itself, or
|
|
1897
|
+
# a typed sign-up whose only credential is connecting one.
|
|
1898
|
+
def pending_registration_holds_provider? = omniauth_registration? || pre_account_provider_enrollment?
|
|
1899
|
+
|
|
1900
|
+
# Compatibility for older, non-emission helpers while callers migrate to the
|
|
1901
|
+
# capability they actually need.
|
|
1902
|
+
alias pending_registration? pending_registration_model?
|
|
1903
|
+
|
|
1904
|
+
# What the sign-up form renders errors and values from. A staged build is filling
|
|
1905
|
+
# in a PendingRegistration, not a half-built account, and the view should say so.
|
|
1906
|
+
def registration_form_object = typed_staged_registration? ? "@registration" : "@user"
|
|
1907
|
+
|
|
1908
|
+
# Whether a sign-up can arrive with no channel to prove at all. Such a request has
|
|
1909
|
+
# nothing to wait for and nothing to hide, so it becomes an account immediately.
|
|
1910
|
+
#
|
|
1911
|
+
# Requires --no-contactable: with the default on, an account holding no channel is
|
|
1912
|
+
# refused either by must_be_contactable or by the column's own presence rule, so
|
|
1913
|
+
# this branch and everything it guards would be unreachable. Keyed to the flag
|
|
1914
|
+
# rather than to "every channel happens to be optional", so a build only has this
|
|
1915
|
+
# shape when it asked for it.
|
|
1916
|
+
def channel_less_registration?
|
|
1917
|
+
!contactable? && pending_registration_model? &&
|
|
1918
|
+
principals.select(&:channel?).all?(&:optional?) && !login_key_may_be_missing?
|
|
1919
|
+
end
|
|
1920
|
+
|
|
1921
|
+
# What a staged sign-up carries over to the account besides the channel that
|
|
1922
|
+
# just proved it: every principal that needs no proof, plus the password when
|
|
1923
|
+
# there is one.
|
|
1924
|
+
def staged_attributes_entries
|
|
1925
|
+
entries = principals.reject(&:channel?).map { |principal| "#{principal.column}: #{principal.column}" }
|
|
1926
|
+
entries << "password_digest: password_digest" if password_on_sign_up_form?
|
|
1927
|
+
entries
|
|
1928
|
+
end
|
|
1929
|
+
|
|
1930
|
+
# Empty in a build whose only principals are channels and whose door needs no
|
|
1931
|
+
# password — a magic-link-only sign-up carries nothing across. There the method
|
|
1932
|
+
# and its splat are both skipped rather than emitted as a `{}` nobody reads.
|
|
1933
|
+
def staged_attributes? = staged_attributes_entries.any?
|
|
1934
|
+
|
|
1935
|
+
def staged_attributes_literal(indent:) = hash_literal(staged_attributes_entries, indent: indent)
|
|
1936
|
+
|
|
1937
|
+
# How the carried values ride into a create! whose channel columns are already
|
|
1938
|
+
# spelled out literally. A trailing splat, or nothing at all.
|
|
1939
|
+
def staged_attributes_splat = (", **staged_attributes" if staged_attributes?)
|
|
1940
|
+
|
|
1941
|
+
# The channels a staged row can mint an account from. A deferred phone is absent
|
|
1942
|
+
# on purpose: it mints nothing, it is parked on an account the email already minted.
|
|
1943
|
+
def minting_channel_columns
|
|
1944
|
+
columns = []
|
|
1945
|
+
columns << principals.email.column if email_verifiable?
|
|
1946
|
+
columns << principals.phone.column if phone_registration_claim?
|
|
1947
|
+
columns
|
|
1948
|
+
end
|
|
1949
|
+
|
|
1950
|
+
# Whether PendingRegistration has a private section to open at all. A build whose
|
|
1951
|
+
# sign-up carries nothing, defers nothing and claims no username has every one of
|
|
1952
|
+
# its methods public, and a bare `private` before `end` would be the only thing
|
|
1953
|
+
# under it.
|
|
1954
|
+
def pending_registration_privates?
|
|
1955
|
+
staged_attributes? || omniauth? || !principals.username.nil?
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
# Passwordless sign-in by texted code — a door, the phone sibling of --magic-link.
|
|
1959
|
+
# The phone principal is explicit; validate_sms_code_option! requires it.
|
|
1960
|
+
def sms_code? = options.sms_code?
|
|
1961
|
+
|
|
1962
|
+
# Whether an emailed link is the gate a staged sign-up hands off to — the sibling
|
|
1963
|
+
# of phone_gated_registration?. It decides everything that link touches: the
|
|
1964
|
+
# mailer method and its view, the token purpose and its expiry key, the "check
|
|
1965
|
+
# your email" page, and the controller that redeems it.
|
|
1966
|
+
#
|
|
1967
|
+
# Distinct from email_verifiable?, which only says there is an address worth
|
|
1968
|
+
# proving; an account can be asked to prove one long after sign-up.
|
|
1969
|
+
def email_gated_registration?
|
|
1970
|
+
email_verifiable? && typed_staged_registration? && typed_sign_up_may_owe?(principals.email)
|
|
1971
|
+
end
|
|
1972
|
+
|
|
1973
|
+
# An invite-only sign-up arrives with the invited channel proved and drops an optional
|
|
1974
|
+
# other one, so it can only owe a required channel the invitation might not have used.
|
|
1975
|
+
def typed_sign_up_may_owe?(principal)
|
|
1976
|
+
!invite_only? || (principal.required? && multi_channel_invitable?)
|
|
1977
|
+
end
|
|
1978
|
+
|
|
1979
|
+
# The sign-up form never collects the channel: it is the invitation's address.
|
|
1980
|
+
def invitation_pins_only_channel? = invite_only? && !multi_channel_invitable?
|
|
1981
|
+
|
|
1982
|
+
# Where to send someone whose staged sign-up is gone. The sign-up form, when this
|
|
1983
|
+
# build has one — an omniauth-only build stages from the callback and has no form
|
|
1984
|
+
# to go back to, so it offers the sign-in page and its provider buttons instead.
|
|
1985
|
+
def registration_entry_path
|
|
1986
|
+
return "#{identity.route_scope}_sign_up_path" if typed_registration?
|
|
1987
|
+
|
|
1988
|
+
"#{identity.route_scope}_sign_in_path"
|
|
1989
|
+
end
|
|
1990
|
+
|
|
1991
|
+
# Whether an SMS code is the gate a typed sign-up must clear — the phone sibling
|
|
1992
|
+
# of email_gated_registration?.
|
|
1993
|
+
def typed_phone_gated_registration?
|
|
1994
|
+
phone_verifiable? && typed_staged_registration? && typed_sign_up_may_owe?(principals.phone)
|
|
1995
|
+
end
|
|
1996
|
+
|
|
1997
|
+
# Either sign-up proves its number this way: the typed form, or a provider's profile form.
|
|
1998
|
+
def phone_gated_registration? = typed_phone_gated_registration? || omniauth_registration_verifies_phone?
|
|
1999
|
+
|
|
2000
|
+
# Non-disclosure of whether an account exists — Devise calls this "paranoid";
|
|
2001
|
+
# named --coy here instead, since that's closer to what it actually does (evasive,
|
|
2002
|
+
# not fearful). Off by default, for the better user experience; with --coy every
|
|
2003
|
+
# channel-principal flow that could confirm an address (password reset,
|
|
2004
|
+
# magic-link request, email change, the locked-account message) gives the same
|
|
2005
|
+
# response whether or not the address is on file. Responses only: the coy
|
|
2006
|
+
# branches still differ in timing. This is a *generation-time* choice, not a runtime
|
|
2007
|
+
# config, so the unchosen branch never ships to the app. Public principals
|
|
2008
|
+
# (username) are honest regardless (Principal#publicly_unique?), and
|
|
2009
|
+
# --no-verifiable independently forces honest *sign-up*, since the "check your
|
|
2010
|
+
# email" step was what covered the coy answer.
|
|
2011
|
+
def coy? = options.coy?
|
|
2012
|
+
def magic_link? = options.magic_link?
|
|
2013
|
+
# Teams are absent unless --teams is present. Each mode sets Current.team its own
|
|
2014
|
+
# way — the route, the middleware, or the session — and team_members_only then
|
|
2015
|
+
# checks membership without knowing which.
|
|
2016
|
+
def teams? = options[:teams].present?
|
|
2017
|
+
def scope_teams? = options[:teams] == "scope"
|
|
2018
|
+
def middleware_teams? = options[:teams] == "middleware"
|
|
2019
|
+
def session_carries_team? = options[:teams] == "session"
|
|
2020
|
+
|
|
2021
|
+
def team_root_helper = identity.nested_helper("team_root_path")
|
|
2022
|
+
|
|
2023
|
+
def team_foreign_key = identity.namespaced? ? "{ to_table: :#{identity.teams_table} }" : "true"
|
|
2024
|
+
|
|
2025
|
+
# A page an emitted test can visit that runs the team gate. Every generated page
|
|
2026
|
+
# is a remedy that skips it, so this is the team's own page or the host's root,
|
|
2027
|
+
# and a namespaced identity keeping its team in the session has neither.
|
|
2028
|
+
def team_gated_page
|
|
2029
|
+
return "#{team_root_helper}(team_id: #{identity.teams_table}(:acme))" if scope_teams?
|
|
2030
|
+
|
|
2031
|
+
"root_path" unless identity.namespaced?
|
|
2032
|
+
end
|
|
2033
|
+
|
|
2034
|
+
def admin_module = identity.nested_module("Admin")
|
|
2035
|
+
def admin_controller = identity.nested_module("AdminController")
|
|
2036
|
+
def admin_helper(name) = identity.nested_helper("admin_#{name}")
|
|
2037
|
+
|
|
2038
|
+
def team_id_constraint = primary_key_type == "uuid" ? '/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/' : '/\d+/'
|
|
2039
|
+
# Development-only "sign in as anyone" shortcut. What it *includes* depends on
|
|
2040
|
+
# the primary auth strategy: with a password it's the sign-in-form bypass
|
|
2041
|
+
# (the EasyDevLogin concern); passwordless it's an explicit dev-only email
|
|
2042
|
+
# field + endpoint (there's no credential form to piggyback). Both share the
|
|
2043
|
+
# "type an email, become that user" gesture.
|
|
2044
|
+
def easy_dev_login? = options.easy_dev_login?
|
|
2045
|
+
def pwned? = options.pwned?
|
|
2046
|
+
# Password strength scoring is a second, orthogonal password-quality validation
|
|
2047
|
+
# (see --pwned): both are `validates :password` lines, and either one implies
|
|
2048
|
+
# --password since there's no password to score without it.
|
|
2049
|
+
def strong_passwords? = options.strong_passwords?
|
|
2050
|
+
# A human/anti-abuse challenge on the *unauthenticated* write forms. It's a
|
|
2051
|
+
# property of the request (verified in a controller concern), not of the User
|
|
2052
|
+
# record — so unlike --pwned/--strong-passwords it never touches the model. Turnstile is
|
|
2053
|
+
# the shipped provider; Captcha.provider in app/lib/captcha.rb is the seam for others.
|
|
2054
|
+
def captchable? = options.captchable?
|
|
2055
|
+
|
|
2056
|
+
# The widget does not survive a Turbo body swap, and these forms re-render on failure.
|
|
2057
|
+
def captcha_form_options = captchable? ? ", data: {turbo: false}" : ""
|
|
2058
|
+
|
|
2059
|
+
# Re-prove before sensitive actions. Bar-aware: it clears the user's strongest
|
|
2060
|
+
# available factor, so (unlike before) it does NOT imply --password.
|
|
2061
|
+
def sudoable? = options.sudoable?
|
|
2062
|
+
|
|
2063
|
+
# The user a sudo re-prove must clear: always the signed-in admin, never the
|
|
2064
|
+
# person they're impersonating.
|
|
2065
|
+
def sudo_subject = "#{identity.current_class}.#{impersonatable? ? "true_user" : "user"}"
|
|
2066
|
+
|
|
2067
|
+
# The sudo bars this build can pose, in the order Sudoable#sudo_strategy tries
|
|
2068
|
+
# them. Ordered by what the person can actually produce at the keyboard right
|
|
2069
|
+
# now, not by cryptographic strength:
|
|
2070
|
+
#
|
|
2071
|
+
# :webauthn one touch, phishing-resistant, and on the device they're holding.
|
|
2072
|
+
# :password no second device involved, so it is the one bar that is always
|
|
2073
|
+
# available to whoever has one.
|
|
2074
|
+
# :totp last, because ranking it above the password would trap anyone who
|
|
2075
|
+
# signed in with a recovery code after losing the phone.
|
|
2076
|
+
#
|
|
2077
|
+
# :password is terminal when every user in the build has one, which is why :totp
|
|
2078
|
+
# drops out entirely unless --omniauth can produce a passwordless account. That
|
|
2079
|
+
# is also what keeps the emitted method free of unreachable branches.
|
|
2080
|
+
def sudo_bars
|
|
2081
|
+
bars = []
|
|
2082
|
+
bars << :webauthn if webauthn_credentials?
|
|
2083
|
+
bars << :password if password?
|
|
2084
|
+
bars << :totp if totp? && !unconditional_password_bar?
|
|
2085
|
+
bars
|
|
2086
|
+
end
|
|
2087
|
+
|
|
2088
|
+
# Whether an account that can reach a sudo bar might hold no password. Guests are
|
|
2089
|
+
# not in scope: they are never authenticated, so they never reach one, and their
|
|
2090
|
+
# NULL digest doesn't make the bar conditional for anyone who does — which is why
|
|
2091
|
+
# this is password_nullable? minus that clause rather than the same question.
|
|
2092
|
+
def passwordless_account_possible?
|
|
2093
|
+
!password? || password_form_optional? || password_deferred? || omniauth?
|
|
2094
|
+
end
|
|
2095
|
+
|
|
2096
|
+
# Every account in this build has a password it chose, so the password bar always
|
|
2097
|
+
# applies and nothing after it can be reached.
|
|
2098
|
+
def unconditional_password_bar? = password? && !passwordless_account_possible?
|
|
2099
|
+
|
|
2100
|
+
# One bar, and every account has it — so which bar to pose is known at generation
|
|
2101
|
+
# time. sudo_strategy would be a method returning a constant, every dispatch on it
|
|
2102
|
+
# a branch with one arm, and the helper_method that exposes it to the challenge
|
|
2103
|
+
# partial pure ceremony. All of it is emitted away.
|
|
2104
|
+
#
|
|
2105
|
+
# A passkey-only or authenticator-only build does NOT qualify: both fall through to
|
|
2106
|
+
# :open for an account that hasn't enrolled, so the branch is load-bearing there.
|
|
2107
|
+
def single_sudo_bar? = sudo_bars == [:password] && unconditional_password_bar?
|
|
2108
|
+
|
|
2109
|
+
# Sudo can hit the "no re-provable factor" case (a user whose only credential is
|
|
2110
|
+
# omniauth or a magic link) only when the build can produce such a user. There we
|
|
2111
|
+
# fail open rather than lock them out of the guarded action. Same question as the
|
|
2112
|
+
# password bar's, from the other side.
|
|
2113
|
+
def fail_open_sudo? = sudoable? && passwordless_account_possible?
|
|
2114
|
+
|
|
2115
|
+
# ---- credentials an account may remove ------------------------------------
|
|
2116
|
+
|
|
2117
|
+
# Credentials an account may hold and remove: several passkeys or provider
|
|
2118
|
+
# logins, or — where password_removable? below is true — the password itself. Their
|
|
2119
|
+
# settings pages carry a refusal rather than a sudo bar: a bar you can clear is
|
|
2120
|
+
# the wrong answer to an action nobody should be able to take, because the thing
|
|
2121
|
+
# being prevented is not impersonation but lockout, and proving who you are does
|
|
2122
|
+
# not make locking yourself out safe.
|
|
2123
|
+
#
|
|
2124
|
+
# Whether a removal would in fact lock the account is a question about the
|
|
2125
|
+
# ACCOUNT, and this predicate only says which builds need to ask it. The answer
|
|
2126
|
+
# itself is the model's #last_sign_in_method?, which can see the row. Deciding it
|
|
2127
|
+
# per door at generation time cannot: a build with two of them would guard
|
|
2128
|
+
# neither, and a provider-registered user could unlink into an account they can
|
|
2129
|
+
# no longer reach.
|
|
2130
|
+
def removable_credentials? = passkey? || omniauth? || password_removable?
|
|
2131
|
+
|
|
2132
|
+
# Doors that identify an account by something other than a password. Their
|
|
2133
|
+
# presence is what lets --password=optional be offered at all: an account can
|
|
2134
|
+
# decline a password and still have a way in.
|
|
2135
|
+
def non_password_doors? = passkey? || omniauth? || magic_link? || sms_code?
|
|
2136
|
+
|
|
2137
|
+
# What the SIGN-UP FORM does about a password — not what every account holds.
|
|
2138
|
+
# Nothing typed into a form can speak for an account minted somewhere else, so
|
|
2139
|
+
# password_nullable? below asks its own question.
|
|
2140
|
+
#
|
|
2141
|
+
# "optional" carries the sense --contactable gave --email=optional: optional
|
|
2142
|
+
# BECAUSE another door covers you, not "may be absent". WHICH door covers a
|
|
2143
|
+
# given account is a per-row fact. PendingRegistration answers it before account
|
|
2144
|
+
# creation; #last_sign_in_method?(:password) answers it later at removal time.
|
|
2145
|
+
def password_form_optional? = password? && options[:password] == "optional"
|
|
2146
|
+
def password_required? = password_on_sign_up_form? && !password_form_optional?
|
|
2147
|
+
|
|
2148
|
+
# Whether the password is enrolled after proof rather than typed on the initial
|
|
2149
|
+
# form. The unfinished attempt remains a PendingRegistration until the password
|
|
2150
|
+
# and account can commit together.
|
|
2151
|
+
#
|
|
2152
|
+
# The only credential here with a choice about when. A passkey is always enrolled
|
|
2153
|
+
# afterwards, a provider ceremony IS the arrival, and a magic link or SMS code is
|
|
2154
|
+
# not enrolled at all — a verified channel is already the door.
|
|
2155
|
+
def password_deferred? = password? && options[:password] == "deferred"
|
|
2156
|
+
|
|
2157
|
+
# Whether the sign-up form carries a password field at all. Sites that care about
|
|
2158
|
+
# the FORM ask this; sites that care about the BUILD ask #password?.
|
|
2159
|
+
def password_on_sign_up_form? = password? && !password_deferred?
|
|
2160
|
+
|
|
2161
|
+
# Whether any path in this build can mint an account with no password on file.
|
|
2162
|
+
# Decides the column's nullability, whether the presence rule can be lifted from
|
|
2163
|
+
# a row, and whether settings needs a way to SET a first password.
|
|
2164
|
+
#
|
|
2165
|
+
# Three paths can: a form that lets the field be blank, the OmniAuth callback (a
|
|
2166
|
+
# provider hands back no password and never will), and guest creation. The other
|
|
2167
|
+
# doors cannot — a --password --passkey build still collects a password from
|
|
2168
|
+
# everyone who signs up, and the passkey is an addition to it.
|
|
2169
|
+
#
|
|
2170
|
+
# Which ROWS may lack one, not which builds have another door. Where this is true
|
|
2171
|
+
# the excused rows hold a real NULL rather than a synthesized password nobody
|
|
2172
|
+
# can type.
|
|
2173
|
+
def password_nullable? = password? && (password_form_optional? || password_deferred? || omniauth? || guestable?)
|
|
2174
|
+
|
|
2175
|
+
# Whether settings offers to REMOVE a password. Only where the form called it
|
|
2176
|
+
# optional on the way in: a build that insists on one at sign-up does not then
|
|
2177
|
+
# offer a door out of it. An account minted without a password can still SET
|
|
2178
|
+
# one wherever password_nullable? holds — that is the add half, and it is not
|
|
2179
|
+
# this question.
|
|
2180
|
+
def password_removable? = password_form_optional? || password_deferred?
|
|
2181
|
+
|
|
2182
|
+
# Which ROWS may carry a NULL digest, as an expression the model can evaluate on
|
|
2183
|
+
# itself. Generated only where password_nullable? holds.
|
|
2184
|
+
#
|
|
2185
|
+
# Where the form called the password optional, that is every row and the answer
|
|
2186
|
+
# is a constant. Otherwise the form required one from everyone it collected, and
|
|
2187
|
+
# the rows excused are exactly the ones no form ever touched.
|
|
2188
|
+
def password_blank_rows_expression
|
|
2189
|
+
[("guest?" if guestable?), ("omniauth_identities.any?" if omniauth?)].compact.join(" || ")
|
|
2190
|
+
end
|
|
2191
|
+
|
|
2192
|
+
# The same question asked of a sign-up in progress. A typed row came through a form
|
|
2193
|
+
# that asked for a password; a provider row holds the provider login instead.
|
|
2194
|
+
def staged_password_blank_rows_expression = pending_registration_holds_provider? ? "provider.present?" : "false"
|
|
2195
|
+
|
|
2196
|
+
# Whether EVERY row may lack a password, on every model that includes the policy.
|
|
2197
|
+
# Where the form called the password optional or deferred it did so for everyone,
|
|
2198
|
+
# so no row is excused more than any other — and a predicate that cannot answer
|
|
2199
|
+
# anything but true is not a seam. PasswordPolicy drops the presence error flatly
|
|
2200
|
+
# and neither model generates #password_may_be_blank? at all.
|
|
2201
|
+
def password_blank_rows_all? = password_form_optional? || password_deferred?
|
|
2202
|
+
|
|
2203
|
+
# Whether a guest is excused the phone-presence rule — the only place a SHARED
|
|
2204
|
+
# validation asks a row whether it is a guest. PendingRegistration defines
|
|
2205
|
+
# `guest? = false` to answer this and nothing else, so the stub follows it.
|
|
2206
|
+
#
|
|
2207
|
+
# Not simply guestable?: an optional phone has no presence rule to excuse, and the
|
|
2208
|
+
# format rule excuses nobody — allow_blank already skips the nil phone every guest carries.
|
|
2209
|
+
def guest_excused_from_phone? = guestable? && principals.phone&.required? || false
|
|
2210
|
+
|
|
2211
|
+
# Too many failed sign-ins deadbolt the password door shut for a cooling-off window.
|
|
2212
|
+
#
|
|
2213
|
+
# The deadbolt defends the password and nothing else, so it bars the password door
|
|
2214
|
+
# and nothing else: a deadbolted account still signs in with its passkey, its magic
|
|
2215
|
+
# link, its provider. Barring those would buy nothing — the attacker never held
|
|
2216
|
+
# them — and would hand any stranger who knows an email address a switch for
|
|
2217
|
+
# locking its owner out of doors that were never under attack.
|
|
2218
|
+
#
|
|
2219
|
+
# It needs a guessable secret to count guesses against, so it needs a password:
|
|
2220
|
+
# a WebAuthn assertion either verifies or raises, and nobody brute-forces a
|
|
2221
|
+
# signed magic-link token. `--deadboltable` without `--password` is refused rather
|
|
2222
|
+
# than silently ignored.
|
|
2223
|
+
#
|
|
2224
|
+
# Enforced inside authenticate_with_password, where the password is checked, so
|
|
2225
|
+
# no sign-in door can forget it — no door does the checking. Not in the
|
|
2226
|
+
# start_new_session_for funnel: a deadbolt is not a fact about the account.
|
|
2227
|
+
def deadboltable? = options.deadboltable? && password?
|
|
2228
|
+
|
|
2229
|
+
# An account can be suspended by an admin: barred from every sign-in door this
|
|
2230
|
+
# build has, with its live sessions destroyed. Distinct from --deadboltable, which
|
|
2231
|
+
# is automatic, password-only, and clears itself — a ban is a deliberate policy
|
|
2232
|
+
# action, so it carries its own state (banned_at, banned_until), its own admin
|
|
2233
|
+
# control, and its own unban path. The two never touch each other's column.
|
|
2234
|
+
# Enforced at the choke point every door funnels through (start_new_session_for),
|
|
2235
|
+
# because unlike a deadbolt a ban *is* a fact about the account. Always named to the
|
|
2236
|
+
# user — it's a message you mean to deliver — so it ignores --coy.
|
|
2237
|
+
#
|
|
2238
|
+
# A ban may carry an expiry (banned_until); banned? reads it, so a temporary ban
|
|
2239
|
+
# lifts itself with nothing scheduled to sweep it.
|
|
2240
|
+
def bannable? = options.bannable?
|
|
2241
|
+
|
|
2242
|
+
def omniauth? = options.omniauth?
|
|
2243
|
+
|
|
2244
|
+
# Required principals an OmniAuth provider can't supply: a username, a phone. Where
|
|
2245
|
+
# any exist, a provider sign-up is staged and a form collects them before the
|
|
2246
|
+
# account exists. Where none do, the callback creates the account itself.
|
|
2247
|
+
def omniauth_registration_principals
|
|
2248
|
+
principals.required.select { |p| %i[username phone].include?(p.type) }
|
|
2249
|
+
end
|
|
2250
|
+
|
|
2251
|
+
def omniauth_registration? = omniauth? && omniauth_registration_principals.any?
|
|
2252
|
+
def omniauth_registration_collects_phone? = omniauth? && omniauth_registration_principals.any? { |p| p.type == :phone }
|
|
2253
|
+
|
|
2254
|
+
# "username.blank? || phone.blank?": the provider row still owes its profile form.
|
|
2255
|
+
def omniauth_profile_missing_condition = omniauth_registration_principals.map { |p| "#{p.column}.blank?" }.join(" || ")
|
|
2256
|
+
|
|
2257
|
+
# The omniauth registration form's permitted params — the columns the provider couldn't
|
|
2258
|
+
# supply (e.g. ":username"). Emitted into the registrations controller's permit.
|
|
2259
|
+
def omniauth_registration_permits
|
|
2260
|
+
omniauth_registration_principals.map { |p| ":#{p.column}" }.join(", ")
|
|
2261
|
+
end
|
|
2262
|
+
|
|
2263
|
+
# What an emitted test types into that form.
|
|
2264
|
+
def omniauth_profile_params_literal
|
|
2265
|
+
values = omniauth_registration_principals.map { |p| p.type == :phone ? %(phone: "+14158264003") : %(#{p.column}: "provideruser") }
|
|
2266
|
+
"{ #{values.join(", ")} }"
|
|
2267
|
+
end
|
|
2268
|
+
|
|
2269
|
+
# The form's number is proved by a texted code before the account exists.
|
|
2270
|
+
def omniauth_registration_verifies_phone? = omniauth_registration_collects_phone? && phone_verifiable?
|
|
2271
|
+
|
|
2272
|
+
# The only door is a provider, so the callback is the only way an account is ever
|
|
2273
|
+
# made. A typed form here would collect an address the provider supplies anyway
|
|
2274
|
+
# and end at "now connect an account".
|
|
2275
|
+
def social_login_only? = omniauth? && !password? && !passkey? && !magic_link? && !sms_code?
|
|
2276
|
+
|
|
2277
|
+
# The registration mode is one policy choice. Invitation modes generate the
|
|
2278
|
+
# invitation machinery; invite-only additionally gates every account-creating
|
|
2279
|
+
# flow on a still-pending invitation.
|
|
2280
|
+
def invitable? = %w[open-and-invites invite-only].include?(options[:registration])
|
|
2281
|
+
def invite_only? = options[:registration] == "invite-only"
|
|
2282
|
+
def email_invitable? = invitable? && !principals.email.nil?
|
|
2283
|
+
def phone_invitable? = invitable? && !principals.phone.nil?
|
|
2284
|
+
def multi_channel_invitable? = email_invitable? && phone_invitable?
|
|
2285
|
+
|
|
2286
|
+
def invitation_recipient_label
|
|
2287
|
+
return "Email or phone" if multi_channel_invitable?
|
|
2288
|
+
|
|
2289
|
+
email_invitable? ? "Email" : "Phone"
|
|
2290
|
+
end
|
|
2291
|
+
|
|
2292
|
+
# A staged flow can mint an account from a phone invitation even where required
|
|
2293
|
+
# email means phone would never be the ordinary sign-up verification gate.
|
|
2294
|
+
def phone_registration_claim? = phone_gated_registration? || (pending_registration_model? && phone_invitable?)
|
|
2295
|
+
|
|
2296
|
+
# Guests are anonymous, session-backed User rows.
|
|
2297
|
+
def guestable? = options.guestable?
|
|
2298
|
+
|
|
2299
|
+
# The admin boolean is its own concern; the admin UI and impersonation both need it.
|
|
2300
|
+
def adminable? = options.adminable? || admin_dashboard? || impersonatable?
|
|
2301
|
+
def admin_dashboard? = options.admin_dashboard?
|
|
2302
|
+
def impersonatable? = options.impersonatable?
|
|
2303
|
+
|
|
2304
|
+
# The enforced-MFA gate for admins only makes sense when both areas exist.
|
|
2305
|
+
def admin_mfa_gate? = admin_dashboard? && second_factor?
|
|
2306
|
+
|
|
2307
|
+
# Where an MFA-less admin is held. The settings enrollment page for the factor
|
|
2308
|
+
# they can reach with nothing but a phone — TOTP where this build has it — so a
|
|
2309
|
+
# locked-out admin is never asked for hardware they may not be holding. Read by
|
|
2310
|
+
# the concern and by the tests.
|
|
2311
|
+
def admin_mfa_hold_path
|
|
2312
|
+
return "new_#{identity.settings_helper_prefix}_mfa_authenticator_path" if totp?
|
|
2313
|
+
return "new_#{identity.settings_helper_prefix}_mfa_sms_path" if sms_second_factor?
|
|
2314
|
+
|
|
2315
|
+
"new_#{identity.settings_helper_prefix}_mfa_security_key_path"
|
|
2316
|
+
end
|
|
2317
|
+
|
|
2318
|
+
# Authorization — role gates (admin) and per-resource access checks (team
|
|
2319
|
+
# membership) — lives in its own controller concern, separate from proving
|
|
2320
|
+
# identity (Authentication). Generated only when there's a gate to house.
|
|
2321
|
+
def authorization? = adminable? || teams?
|
|
2322
|
+
|
|
2323
|
+
# ---------------------------------------------------------------------------
|
|
2324
|
+
# Ruby written into the generated TEST suite
|
|
2325
|
+
#
|
|
2326
|
+
# Nothing below reaches app code — every one of these is read only by a
|
|
2327
|
+
# template under templates/test_unit. Fixture passwords, sudo answers and the
|
|
2328
|
+
# sign-in gesture live here so a test template never has to ask which build
|
|
2329
|
+
# shape it is in.
|
|
2330
|
+
# ---------------------------------------------------------------------------
|
|
2331
|
+
|
|
2332
|
+
# The body of an emitted test's `clear_sudo_bar` helper. Enrolling a credential is
|
|
2333
|
+
# guarded by require_sudo_within, so a test that enrols has to stamp sudo_at first —
|
|
2334
|
+
# once, since the window then covers the rest of the test. Without a password the
|
|
2335
|
+
# bar is a key once the account holds one, and nothing before; fixtures never hold
|
|
2336
|
+
# an enrolled authenticator, so the TOTP bar never comes up.
|
|
2337
|
+
def test_clear_sudo_body
|
|
2338
|
+
return " # No bar to clear: this build has no sudo." unless sudoable?
|
|
2339
|
+
|
|
2340
|
+
if sudo_bars.include?(:password)
|
|
2341
|
+
%( post #{identity.route_scope}_sudo_path, params: { sudo_password: "quilted-lantern-moss-97" })
|
|
2342
|
+
else
|
|
2343
|
+
%( post #{identity.route_scope}_sudo_path, params: { sudo_credential: #{identity.route_scope}_sudo_assertion } if @user.webauthn_credentials.any?)
|
|
2344
|
+
end
|
|
2345
|
+
end
|
|
2346
|
+
|
|
2347
|
+
# The body of an emitted test's `sign_in_as(user)` helper, indented to sit inside
|
|
2348
|
+
# it. "Sign in" is not one gesture: a password build posts the form, a magic-link
|
|
2349
|
+
# build consumes a link, an --sms-code build asks for a code and types it back.
|
|
2350
|
+
# Every test template that needs a signed-in user shares this one choice.
|
|
2351
|
+
def test_sign_in_body
|
|
2352
|
+
if password?
|
|
2353
|
+
%( post #{identity.route_scope}_sign_in_path, params: ) +
|
|
2354
|
+
%({ #{sign_in_field}: user.#{login_field}, password: "quilted-lantern-moss-97" })
|
|
2355
|
+
elsif magic_link?
|
|
2356
|
+
<<~RUBY.gsub(/^(?=.)/, " ").chomp
|
|
2357
|
+
token = user.generate_magic_link
|
|
2358
|
+
patch #{identity.route_scope}_magic_link_path(sid: token)
|
|
2359
|
+
RUBY
|
|
2360
|
+
elsif sms_code?
|
|
2361
|
+
<<~RUBY.gsub(/^(?=.)/, " ").chomp
|
|
2362
|
+
post #{identity.route_scope}_sms_sign_in_path, params: { phone: user.phone }
|
|
2363
|
+
# The code exists only in the outgoing text, so read it back from there.
|
|
2364
|
+
code = ActiveJob::Base.queue_adapter.enqueued_jobs.reverse
|
|
2365
|
+
.filter_map { |job| job.to_s[/(\\d{6}) is your/, 1] }.first
|
|
2366
|
+
patch #{identity.route_scope}_sms_sign_in_code_path, params: { code: code }
|
|
2367
|
+
RUBY
|
|
2368
|
+
else
|
|
2369
|
+
# Passkey and omniauth have no gesture a hermetic test can perform, so this
|
|
2370
|
+
# build's door can't be driven at all. Mint the session directly instead and
|
|
2371
|
+
# hand the client the same signed cookie sign-in would have set, produced with
|
|
2372
|
+
# the app's own signing config. It is a stand-in for the door, not a test of it.
|
|
2373
|
+
#
|
|
2374
|
+
# Such a build also holds every request at the enrollment gate until a credential
|
|
2375
|
+
# exists, so the stand-in has to clear that too — a caller asking for a signed-in
|
|
2376
|
+
# user wants one who can actually reach a page.
|
|
2377
|
+
#
|
|
2378
|
+
# Deliberately ASCII-only. Thor reads templates with binread, so the ERB
|
|
2379
|
+
# buffer is BINARY; concatenating a UTF-8 string into one that already holds
|
|
2380
|
+
# a non-ASCII byte raises Encoding::CompatibilityError at generation time.
|
|
2381
|
+
#
|
|
2382
|
+
# The stand-in passkey is a real key held by @client, so the test can answer
|
|
2383
|
+
# a sudo bar with it (see configure_test_webauthn).
|
|
2384
|
+
lines =
|
|
2385
|
+
if stand_in_passkey?
|
|
2386
|
+
[
|
|
2387
|
+
"@client = WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)",
|
|
2388
|
+
"key = WebAuthn::Credential.from_create(@client.create(rp_id: WebAuthn.configuration.rp_id, user_verified: true))",
|
|
2389
|
+
%(user.webauthn_credentials.create!(name: "Test key", external_id: key.id, public_key: key.public_key#{factor_attr(:first_factor)}))
|
|
2390
|
+
]
|
|
2391
|
+
else
|
|
2392
|
+
[%(user.omniauth_identities.create!(provider: "developer", uid: "uid-\#{user.id}"))]
|
|
2393
|
+
end
|
|
2394
|
+
lines += [
|
|
2395
|
+
"record = user.sessions.create!",
|
|
2396
|
+
%(env = Rails.application.env_config.merge("HTTP_HOST" => "www.example.com")),
|
|
2397
|
+
"jar = ActionDispatch::Request.new(env).cookie_jar",
|
|
2398
|
+
"jar.signed[:#{identity.cookie_name}] = record.id",
|
|
2399
|
+
"cookies[:#{identity.cookie_name}] = jar[:#{identity.cookie_name}]"
|
|
2400
|
+
]
|
|
2401
|
+
lines.map { |line| " #{line}" }.join("\n")
|
|
2402
|
+
end
|
|
2403
|
+
end
|
|
2404
|
+
|
|
2405
|
+
# Whether a hermetic test can perform this build's sign-in gesture. Everything
|
|
2406
|
+
# else gets a minted session (see test_sign_in_body's last branch), which is
|
|
2407
|
+
# enough for tests ABOUT the signed-in area but not for tests about the act of
|
|
2408
|
+
# signing in — those assert on what the sign-in path itself does, and a stand-in
|
|
2409
|
+
# that never travels that path can't satisfy them.
|
|
2410
|
+
def test_drivable_door? = password? || magic_link? || sms_code?
|
|
2411
|
+
|
|
2412
|
+
def stand_in_passkey? = passkey? && !test_drivable_door?
|
|
2413
|
+
|
|
2414
|
+
# The principal half of a generated test's sign-up fixture. The interpolations
|
|
2415
|
+
# are escaped (`\#{`) so they survive into the emitted file intact: they belong
|
|
2416
|
+
# to the test, and run when it does, not while this generator writes it.
|
|
2417
|
+
def fixture_principal_entries(with_email:)
|
|
2418
|
+
entries = []
|
|
2419
|
+
entries << %(email: "new-\#{SecureRandom.hex(4)}@example.com") if with_email
|
|
2420
|
+
entries << %(username: "user_\#{SecureRandom.hex(4)}") if principals.username
|
|
2421
|
+
entries << %(phone: "+1415826\#{format('%04d', SecureRandom.random_number(10_000))}") if principals.phone
|
|
2422
|
+
entries
|
|
2423
|
+
end
|
|
2424
|
+
|
|
2425
|
+
# What the emitted model test's build_<user> helper starts from.
|
|
2426
|
+
def account_fixture_literal(indent:)
|
|
2427
|
+
entries = fixture_principal_entries(with_email: !principals.email.nil?)
|
|
2428
|
+
entries << %(password: "quilted-lantern-moss-97") if password?
|
|
2429
|
+
hash_literal(entries, indent: indent)
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2432
|
+
# What the emitted controller test posts to sign up. Two differences from the
|
|
2433
|
+
# model fixture: an invite-only build's address comes from the invitation rather
|
|
2434
|
+
# than the form, and a form submits a confirmation beside the password.
|
|
2435
|
+
def sign_up_fixture_literal(indent:)
|
|
2436
|
+
entries = fixture_principal_entries(with_email: !principals.email.nil? && !invitation_pins_only_channel?)
|
|
2437
|
+
entries.concat([%(password: "quilted-lantern-moss-97"), %(password_confirmation: "quilted-lantern-moss-97")]) if password?
|
|
2438
|
+
hash_literal(entries, indent: indent)
|
|
2439
|
+
end
|
|
2440
|
+
|
|
2441
|
+
# How an emitted test gives the fixture a way in that isn't its password, so the
|
|
2442
|
+
# removal guard stands down. Whichever non-password door this build actually has.
|
|
2443
|
+
def password_optional_second_door_setup
|
|
2444
|
+
if omniauth?
|
|
2445
|
+
%(@user.omniauth_identities.create!(provider: "developer", uid: "a-second-way-in"))
|
|
2446
|
+
elsif passkey?
|
|
2447
|
+
%(@user.webauthn_credentials.create!(name: "Key", external_id: "ext-second-way-in", public_key: "pk"#{factor_attr(:first_factor)}))
|
|
2448
|
+
else
|
|
2449
|
+
# --magic-link and --sms-code ride a principal the fixture already holds, so
|
|
2450
|
+
# there is nothing to create: the door is open the moment the account exists.
|
|
2451
|
+
"# #{magic_link? ? "the address" : "the number"} on file is already the second way in"
|
|
2452
|
+
end
|
|
2453
|
+
end
|
|
2454
|
+
|
|
2455
|
+
# Put a second factor on a record directly, for tests that need one enrolled
|
|
2456
|
+
# rather than exercising the enrollment flow itself.
|
|
2457
|
+
# The challenge an emitted test lands on after the first factor: the one
|
|
2458
|
+
# enroll_second_factor_expr put on the fixture.
|
|
2459
|
+
def enrolled_second_factor_challenge_path
|
|
2460
|
+
return second_factor_challenge_path_for("totp") if totp?
|
|
2461
|
+
return second_factor_challenge_path_for("sms") if sms_second_factor?
|
|
2462
|
+
|
|
2463
|
+
second_factor_challenge_path_for("security_keys")
|
|
2464
|
+
end
|
|
2465
|
+
|
|
2466
|
+
def enroll_second_factor_expr(subject)
|
|
2467
|
+
return "#{subject}.update!(totp_enrolled_at: Time.current)" if totp?
|
|
2468
|
+
return "#{subject}.update!(sms_enrolled_at: Time.current)" if sms_second_factor?
|
|
2469
|
+
|
|
2470
|
+
%(#{subject}.webauthn_credentials.create!(name: "Key", external_id: "ext-\#{#{subject}.id}", public_key: "pk"#{factor_attr(:second_factor)}))
|
|
2471
|
+
end
|
|
2472
|
+
|
|
2473
|
+
# What an emitted *settings* test can expect when it hits a sudo-guarded action:
|
|
2474
|
+
# :answer when the fixture can meet the bar (see sudo_test_answer), :open when
|
|
2475
|
+
# there is no bar this account can be asked and sudo fails open.
|
|
2476
|
+
def sudo_test_mode = sudoable? && sudo_test_answer ? :answer : :open
|
|
2477
|
+
|
|
2478
|
+
# The sudo answer a generated test sends with a guarded request. Fixture accounts
|
|
2479
|
+
# hold no authenticator enrollment, so the bars they can meet are the password and
|
|
2480
|
+
# the stand-in passkey's assertion; without either, sudo fails open. A TOTP bar is
|
|
2481
|
+
# deliberately absent: sudo_strategy poses it only to an enrolled account, so a
|
|
2482
|
+
# fixture carrying a totp_secret still clears sudo without answering, and sending
|
|
2483
|
+
# a code would look like coverage that isn't there.
|
|
2484
|
+
def sudo_test_answer
|
|
2485
|
+
return %(sudo_password: "quilted-lantern-moss-97") if sudo_bars.include?(:password)
|
|
2486
|
+
|
|
2487
|
+
%(sudo_credential: #{identity.route_scope}_sudo_assertion) if stand_in_passkey?
|
|
2488
|
+
end
|
|
2489
|
+
|
|
2490
|
+
# The sudo answer spliced into a generated test's params hash. Empty when the build
|
|
2491
|
+
# needs no answer.
|
|
2492
|
+
def sudo_test_params_pair = sudo_test_mode == :answer ? ", #{sudo_test_answer}" : ""
|
|
2493
|
+
|
|
2494
|
+
# The credential fragment a guarded settings write (change your address, change
|
|
2495
|
+
# your number, delete your account) must carry: the sudo answer where those
|
|
2496
|
+
# controllers are guarded, or a password_challenge where they collect one
|
|
2497
|
+
# themselves.
|
|
2498
|
+
def settings_credential_pair
|
|
2499
|
+
return sudo_test_params_pair if sudoable?
|
|
2500
|
+
|
|
2501
|
+
password? ? %(, password_challenge: "quilted-lantern-moss-97") : ""
|
|
2502
|
+
end
|
|
2503
|
+
|
|
2504
|
+
# The OmniAuth callback path a generated test posts to (the route is unnamed, so
|
|
2505
|
+
# tests use the literal path). Namespaced identities carry it under their prefix, the
|
|
2506
|
+
# same place namespaced_omniauth_routes mounts the callbacks.
|
|
2507
|
+
def omniauth_callback_test_path
|
|
2508
|
+
prefix = "/#{identity.path_prefix}" if identity.namespaced?
|
|
2509
|
+
"#{prefix}/auth/developer/callback"
|
|
2510
|
+
end
|
|
2511
|
+
|
|
2512
|
+
# The OmniAuth failure path a generated test hits (unnamed route, like the
|
|
2513
|
+
# callback above). Namespaced identities carry it under their prefix.
|
|
2514
|
+
def omniauth_failure_test_path
|
|
2515
|
+
prefix = "/#{identity.path_prefix}" if identity.namespaced?
|
|
2516
|
+
"#{prefix}/auth/failure"
|
|
2517
|
+
end
|
|
2518
|
+
|
|
2519
|
+
# ---------------------------------------------------------------------------
|
|
2520
|
+
# Routes
|
|
2521
|
+
# ---------------------------------------------------------------------------
|
|
2522
|
+
|
|
2523
|
+
def identity_scope_args
|
|
2524
|
+
path = %("#{identity.path_prefix}", ) if identity.namespaced?
|
|
2525
|
+
"#{path}module: :#{identity.controller_module}, as: :#{identity.route_scope}"
|
|
2526
|
+
end
|
|
2527
|
+
|
|
2528
|
+
def route_block
|
|
2529
|
+
clean_route_block(<<~RUBY)
|
|
2530
|
+
scope #{identity_scope_args} do
|
|
2531
|
+
get "sign_in", to: "sessions#new"#{password? ? %(\n post "sign_in", to: "sessions#create") : ""}
|
|
2532
|
+
delete "sign_out", to: "sessions#destroy"
|
|
2533
|
+
#{user_scope_route_blocks}end
|
|
2534
|
+
|
|
2535
|
+
#{settings_namespace}
|
|
2536
|
+
#{top_level_route_blocks}
|
|
2537
|
+
RUBY
|
|
2538
|
+
end
|
|
2539
|
+
|
|
2540
|
+
# The namespaced shape: one outer scope, path-prefixed under the identity's own
|
|
2541
|
+
# name, wraps *everything* — doors, settings, teams, admin, omniauth callbacks —
|
|
2542
|
+
# plus a subroot (<identity>_root_path). That is what keeps a second identity
|
|
2543
|
+
# from colliding with the first on bare paths like /sign_in or /admin.
|
|
2544
|
+
def namespaced_route_block
|
|
2545
|
+
clean_route_block(<<~RUBY)
|
|
2546
|
+
scope #{identity_scope_args} do
|
|
2547
|
+
get "sign_in", to: "sessions#new"#{password? ? %(\n post "sign_in", to: "sessions#create") : ""}
|
|
2548
|
+
delete "sign_out", to: "sessions#destroy"
|
|
2549
|
+
#{user_scope_route_blocks}
|
|
2550
|
+
#{settings_namespace(indent: 2)}
|
|
2551
|
+
#{namespaced_omniauth_routes if omniauth?}
|
|
2552
|
+
#{area_route_blocks.gsub(/^(?=.)/, " ")}
|
|
2553
|
+
root to: "settings/dashboard#index"
|
|
2554
|
+
end
|
|
2555
|
+
#{namespaced_top_level_route_blocks}
|
|
2556
|
+
RUBY
|
|
2557
|
+
end
|
|
2558
|
+
|
|
2559
|
+
def area_route_blocks
|
|
2560
|
+
route_blocks(
|
|
2561
|
+
(team_routes if teams?),
|
|
2562
|
+
(admin_routes if admin_dashboard?)
|
|
2563
|
+
)
|
|
2564
|
+
end
|
|
2565
|
+
|
|
2566
|
+
def user_scope_route_blocks
|
|
2567
|
+
route_blocks(
|
|
2568
|
+
(registration_routes if typed_registration?),
|
|
2569
|
+
(email_verification_routes if email_verifiable?),
|
|
2570
|
+
(password_reset_routes if recoverable?),
|
|
2571
|
+
(magic_link_routes if magic_link?),
|
|
2572
|
+
(sms_code_routes if sms_code?),
|
|
2573
|
+
(phone_verification_routes if phone_gated_registration?),
|
|
2574
|
+
(passkey_routes if passkey?),
|
|
2575
|
+
(credential_enrollment_routes if credential_staged_registration?),
|
|
2576
|
+
(sudo_routes if sudoable?),
|
|
2577
|
+
(mfa_challenge_routes if second_factor?),
|
|
2578
|
+
trailing: true
|
|
2579
|
+
)
|
|
2580
|
+
end
|
|
2581
|
+
|
|
2582
|
+
# The settings namespace's optional resources, indented to sit inside it. The
|
|
2583
|
+
# individual builders emit at column 0 and are shifted here in one place,
|
|
2584
|
+
# because the namespace sits at a different depth in the two route shapes:
|
|
2585
|
+
# top level by default, one level in once namespaced.
|
|
2586
|
+
def settings_route_blocks(indent)
|
|
2587
|
+
blocks = route_blocks(
|
|
2588
|
+
(settings_invitation_route if invitable?),
|
|
2589
|
+
(mfa_profile_routes if second_factor?),
|
|
2590
|
+
(passkey_profile_routes if passkey?),
|
|
2591
|
+
(omniauth_profile_routes if omniauth?),
|
|
2592
|
+
(authentication_event_routes if trackable?),
|
|
2593
|
+
trailing: true
|
|
2594
|
+
)
|
|
2595
|
+
blocks.gsub(/^(?=.)/, " " * indent)
|
|
2596
|
+
end
|
|
2597
|
+
|
|
2598
|
+
def top_level_route_blocks
|
|
2599
|
+
route_blocks(
|
|
2600
|
+
area_route_blocks,
|
|
2601
|
+
(omniauth_routes if omniauth?),
|
|
2602
|
+
(invitation_routes if invitable?),
|
|
2603
|
+
(easy_dev_login_routes if easy_dev_login? && !password?)
|
|
2604
|
+
)
|
|
2605
|
+
end
|
|
2606
|
+
|
|
2607
|
+
# What a namespaced identity leaves outside its scope. Its omniauth callbacks, teams
|
|
2608
|
+
# and admin area move inside, where a second identity can't collide with them.
|
|
2609
|
+
def namespaced_top_level_route_blocks
|
|
2610
|
+
route_blocks(
|
|
2611
|
+
(namespaced_invitation_routes if invitable?),
|
|
2612
|
+
(easy_dev_login_routes if easy_dev_login? && !password?)
|
|
2613
|
+
)
|
|
2614
|
+
end
|
|
2615
|
+
|
|
2616
|
+
def route_blocks(*blocks, trailing: false)
|
|
2617
|
+
routes = blocks.compact.map(&:chomp).reject(&:empty?).join("\n")
|
|
2618
|
+
routes += "\n" if trailing && !routes.empty?
|
|
2619
|
+
routes
|
|
2620
|
+
end
|
|
2621
|
+
|
|
2622
|
+
# The self-service settings area: a landing page plus the :settings namespace
|
|
2623
|
+
# holding every account-management resource. One builder serves both route
|
|
2624
|
+
# shapes, since only its depth differs — +indent+ 0 for the default identity,
|
|
2625
|
+
# which mounts it at the top level (/settings -> Settings::...), and 2 once
|
|
2626
|
+
# namespaced, where it nests inside the identity's own outer scope, inheriting
|
|
2627
|
+
# that scope's module and as: (/realtors/settings -> Realtors::Settings::...).
|
|
2628
|
+
def settings_namespace(indent: 0)
|
|
2629
|
+
pad = " " * indent
|
|
2630
|
+
body = " " * (indent + 2)
|
|
2631
|
+
|
|
2632
|
+
lines = [%(#{pad}get "settings", to: "settings/dashboard#index"), "#{pad}namespace :settings do"]
|
|
2633
|
+
# :create sets a FIRST password on an account minted without one; :destroy
|
|
2634
|
+
# gives one up. Different questions — a required build still needs :create for
|
|
2635
|
+
# its provider and guest rows, and still offers no way out.
|
|
2636
|
+
password_actions = [":edit", ":update", (":create" if password_nullable?), (":destroy" if password_removable?)].compact
|
|
2637
|
+
lines << "#{body}resource :password, only: [ #{password_actions.join(", ")} ]" if password?
|
|
2638
|
+
lines << "#{body}resource :email, only: [ :edit, :update ]" if email_changeable?
|
|
2639
|
+
lines << "#{body}resource :phone, only: [ :edit, :update ]" if phone_changeable?
|
|
2640
|
+
lines << "#{body}resource :user, only: [ :show, :destroy ]"
|
|
2641
|
+
# Only the change resend lives under /settings. Its link is consumed by
|
|
2642
|
+
# the identity's own EmailVerificationsController at the top level, because the inbox may
|
|
2643
|
+
# be read on a different device with no session. Staged sign-up links use the
|
|
2644
|
+
# sibling Registrations::EmailVerificationsController for their different
|
|
2645
|
+
# collision rule.
|
|
2646
|
+
lines << "#{body}resource :email_verification, only: [ :create ]" if email_change_verification?
|
|
2647
|
+
lines << "#{body}resource :phone_verification, only: [ :new, :create, :update ]" if phone_change_verification?
|
|
2648
|
+
lines << "#{body}resources :sessions, only: [ :index, :destroy ]"
|
|
2649
|
+
lines << "#{body}resources :api_tokens, only: [ :index, :create, :destroy ]" if api_tokens?
|
|
2650
|
+
|
|
2651
|
+
extras = settings_route_blocks(indent + 2).chomp
|
|
2652
|
+
lines << extras unless extras.empty?
|
|
2653
|
+
|
|
2654
|
+
lines << "#{pad}end"
|
|
2655
|
+
lines.join("\n")
|
|
2656
|
+
end
|
|
2657
|
+
|
|
2658
|
+
def clean_route_block(routes)
|
|
2659
|
+
align_route_lines(routes.lines.map(&:rstrip)).join("\n")
|
|
2660
|
+
end
|
|
2661
|
+
|
|
2662
|
+
# Squares up the columns in a finished route block: the first argument, and
|
|
2663
|
+
# whatever follows the first comma (`to:`, `only:`, ...).
|
|
2664
|
+
#
|
|
2665
|
+
# It happens here, after assembly, because no builder above can do it. Which
|
|
2666
|
+
# routes a block contains depends on the flags, so padding written by hand is
|
|
2667
|
+
# padding aimed at a line that may not be generated — and when it isn't, the
|
|
2668
|
+
# leftover spaces align with nothing, which is exactly what RuboCop's
|
|
2669
|
+
# Layout/SpaceBeforeFirstArg objects to (`get "sign_in"` is fine beside a
|
|
2670
|
+
# `post "sign_in"` and a stray double space without it). Aligning once, when the
|
|
2671
|
+
# membership of each group is finally known, is the only way to be right in
|
|
2672
|
+
# every build.
|
|
2673
|
+
#
|
|
2674
|
+
# A group is a run of adjacent route calls sharing one indent. Anything else
|
|
2675
|
+
# breaks it — a blank line, a comment, an `end`, a block opener (`... do`, whose
|
|
2676
|
+
# body sits a level deeper and lines up on its own).
|
|
2677
|
+
def align_route_lines(lines)
|
|
2678
|
+
lines
|
|
2679
|
+
.slice_when { |before, after| !same_route_group?(before, after) }
|
|
2680
|
+
.flat_map { |group| align_route_group(group) }
|
|
2681
|
+
end
|
|
2682
|
+
|
|
2683
|
+
def same_route_group?(before, after)
|
|
2684
|
+
parts = route_line_parts(before)
|
|
2685
|
+
parts && route_line_parts(after)&.fetch(:indent) == parts[:indent]
|
|
2686
|
+
end
|
|
2687
|
+
|
|
2688
|
+
def align_route_group(group)
|
|
2689
|
+
parts = group.map { |line| route_line_parts(line) }
|
|
2690
|
+
return group unless parts.all?
|
|
2691
|
+
|
|
2692
|
+
method_width = parts.map { |part| part[:method].length }.max
|
|
2693
|
+
option_width = parts.filter_map { |part| part[:argument].length if part[:options] }.max || 0
|
|
2694
|
+
|
|
2695
|
+
parts.map do |part|
|
|
2696
|
+
line = "#{part[:indent]}#{part[:method].ljust(method_width)} #{part[:argument]}"
|
|
2697
|
+
line << "#{" " * (option_width - part[:argument].length + 1)}#{part[:options]}" if part[:options]
|
|
2698
|
+
line
|
|
2699
|
+
end
|
|
2700
|
+
end
|
|
2701
|
+
|
|
2702
|
+
# Splits one route call into the pieces the aligner pads between, or nil for a
|
|
2703
|
+
# line that isn't one (a comment, a blank, an `end`, a block opener).
|
|
2704
|
+
def route_line_parts(line)
|
|
2705
|
+
return nil if line.end_with?(" do")
|
|
2706
|
+
|
|
2707
|
+
match = /\A(?<indent> *)(?<method>[a-z_]+) +(?<rest>\S.*)\z/.match(line)
|
|
2708
|
+
return nil unless match
|
|
2709
|
+
|
|
2710
|
+
argument, separator, options = match[:rest].partition(", ")
|
|
2711
|
+
return { indent: match[:indent], method: match[:method], argument: argument, options: nil } if separator.empty?
|
|
2712
|
+
|
|
2713
|
+
{ indent: match[:indent], method: match[:method], argument: "#{argument},", options: options.lstrip }
|
|
2714
|
+
end
|
|
2715
|
+
|
|
2716
|
+
def primary_key_options
|
|
2717
|
+
return "" unless primary_key_type
|
|
2718
|
+
|
|
2719
|
+
", id: #{primary_key_type.to_sym.inspect}"
|
|
2720
|
+
end
|
|
2721
|
+
|
|
2722
|
+
def reference_type_options
|
|
2723
|
+
return "" unless primary_key_type
|
|
2724
|
+
|
|
2725
|
+
", type: #{primary_key_type.to_sym.inspect}"
|
|
2726
|
+
end
|
|
2727
|
+
|
|
2728
|
+
def primary_key_type
|
|
2729
|
+
configured_primary_key_type =
|
|
2730
|
+
options[:primary_key_type] ||
|
|
2731
|
+
active_record_generator_options[:primary_key_type] ||
|
|
2732
|
+
active_record_generator_options["primary_key_type"]
|
|
2733
|
+
|
|
2734
|
+
configured_primary_key_type&.to_s
|
|
2735
|
+
end
|
|
2736
|
+
|
|
2737
|
+
def active_record_generator_options
|
|
2738
|
+
Rails::Generators.options[:active_record] || {}
|
|
2739
|
+
end
|
|
2740
|
+
|
|
2741
|
+
def registration_routes
|
|
2742
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2743
|
+
|
|
2744
|
+
get "sign_up", to: "registrations#new"
|
|
2745
|
+
post "sign_up", to: "registrations#create"
|
|
2746
|
+
RUBY
|
|
2747
|
+
end
|
|
2748
|
+
|
|
2749
|
+
def sudo_routes
|
|
2750
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2751
|
+
|
|
2752
|
+
resource :sudo, only: [ :new, :create ]
|
|
2753
|
+
RUBY
|
|
2754
|
+
end
|
|
2755
|
+
|
|
2756
|
+
def easy_dev_login_routes
|
|
2757
|
+
<<~RUBY
|
|
2758
|
+
|
|
2759
|
+
if Rails.env.development?
|
|
2760
|
+
scope #{identity_scope_args} do
|
|
2761
|
+
post "easy_dev_login", to: "easy_dev_logins#create"
|
|
2762
|
+
end
|
|
2763
|
+
end
|
|
2764
|
+
RUBY
|
|
2765
|
+
end
|
|
2766
|
+
|
|
2767
|
+
def settings_invitation_route
|
|
2768
|
+
<<~RUBY
|
|
2769
|
+
resource :invitation, only: [ :new, :create ]
|
|
2770
|
+
RUBY
|
|
2771
|
+
end
|
|
2772
|
+
|
|
2773
|
+
def authentication_event_routes
|
|
2774
|
+
<<~RUBY
|
|
2775
|
+
namespace :authentications do
|
|
2776
|
+
resources :events, only: :index
|
|
2777
|
+
end
|
|
2778
|
+
RUBY
|
|
2779
|
+
end
|
|
2780
|
+
|
|
2781
|
+
def invitation_routes
|
|
2782
|
+
routes = [%(get "invitations/accept", to: "invitations#show", as: :accept_invitation)]
|
|
2783
|
+
routes << %(patch "invitations/accept", to: "invitations#update") if teams?
|
|
2784
|
+
"\n#{routes.join("\n")}\n"
|
|
2785
|
+
end
|
|
2786
|
+
|
|
2787
|
+
# Routed at the top level (outside the identity's own scope) like
|
|
2788
|
+
# invitation_routes above, but path-prefixed and pointing at this identity's own
|
|
2789
|
+
# namespaced controller and route helper. Both must differ from the incumbent's:
|
|
2790
|
+
# the path so the accept link reaches this identity (a bare "invitations/accept"
|
|
2791
|
+
# collides with the incumbent's identical path and Rails dispatches the first),
|
|
2792
|
+
# and the controller must be named in full in `to:` — an inline `module:` option
|
|
2793
|
+
# alongside a string `to:` does NOT namespace it (it leaks in as a stray param
|
|
2794
|
+
# and the bare InvitationsController handles the request), so spell it out.
|
|
2795
|
+
def namespaced_invitation_routes
|
|
2796
|
+
path = "#{identity.path_prefix}/invitations/accept"
|
|
2797
|
+
routes = [%(get "#{path}", to: "#{identity.controller_module}/invitations#show", as: :#{identity.route_scope}_accept_invitation)]
|
|
2798
|
+
routes << %(patch "#{path}", to: "#{identity.controller_module}/invitations#update") if teams?
|
|
2799
|
+
"\n#{routes.join("\n")}\n"
|
|
2800
|
+
end
|
|
2801
|
+
|
|
2802
|
+
def omniauth_routes
|
|
2803
|
+
registration = <<~RUBY.chomp if omniauth_registration?
|
|
2804
|
+
|
|
2805
|
+
scope "auth", as: :#{identity.route_scope}_omniauth do
|
|
2806
|
+
resource :registration, only: [ :new, :create ], controller: "#{identity.controller_module}/omniauth/registrations"
|
|
2807
|
+
end
|
|
2808
|
+
RUBY
|
|
2809
|
+
<<~RUBY
|
|
2810
|
+
|
|
2811
|
+
post "/auth/:provider/callback", to: "#{identity.controller_module}/omniauth#create"
|
|
2812
|
+
get "/auth/:provider/callback", to: "#{identity.controller_module}/omniauth#create"
|
|
2813
|
+
get "/auth/failure", to: "#{identity.controller_module}/omniauth#failure"#{registration}
|
|
2814
|
+
RUBY
|
|
2815
|
+
end
|
|
2816
|
+
|
|
2817
|
+
# Same routes as omniauth_routes, but relative: this variant lives *inside*
|
|
2818
|
+
# namespaced_route_block's `scope "...", module: ... do`, which already supplies
|
|
2819
|
+
# both the path prefix and the controller module, so /realtor/auth/... is
|
|
2820
|
+
# free and "omniauth#create" (not "realtors/omniauth#create") resolves
|
|
2821
|
+
# correctly.
|
|
2822
|
+
def namespaced_omniauth_routes
|
|
2823
|
+
registration = <<~RUBY.chomp if omniauth_registration?
|
|
2824
|
+
|
|
2825
|
+
scope "auth", as: :omniauth do
|
|
2826
|
+
resource :registration, only: [ :new, :create ], controller: "omniauth/registrations"
|
|
2827
|
+
end
|
|
2828
|
+
RUBY
|
|
2829
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2830
|
+
|
|
2831
|
+
post "auth/:provider/callback", to: "omniauth#create"
|
|
2832
|
+
get "auth/:provider/callback", to: "omniauth#create"
|
|
2833
|
+
get "auth/failure", to: "omniauth#failure"#{registration}
|
|
2834
|
+
RUBY
|
|
2835
|
+
end
|
|
2836
|
+
|
|
2837
|
+
def password_reset_routes
|
|
2838
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2839
|
+
|
|
2840
|
+
get "forgot_password", to: "password_resets#new"
|
|
2841
|
+
post "reset_password", to: "password_resets#create"
|
|
2842
|
+
get "reset_password", to: "password_resets#edit"
|
|
2843
|
+
patch "reset_password", to: "password_resets#update"
|
|
2844
|
+
put "reset_password", to: "password_resets#update"
|
|
2845
|
+
RUBY
|
|
2846
|
+
end
|
|
2847
|
+
|
|
2848
|
+
# Email sign-up and account change have different collision rules, so their
|
|
2849
|
+
# links land at separate endpoints even though both remain reachable signed-out.
|
|
2850
|
+
# A permanent email keeps the sign-up half and drops the change half, which is
|
|
2851
|
+
# why the two are assembled separately rather than as one heredoc.
|
|
2852
|
+
def email_verification_routes
|
|
2853
|
+
lines = []
|
|
2854
|
+
|
|
2855
|
+
# The sign-up click carries its own proof at /verify_email, where GET only
|
|
2856
|
+
# renders the confirm button and PATCH does the work — a mail scanner's GET
|
|
2857
|
+
# must not be able to consume the link. /new remains the waiting page and
|
|
2858
|
+
# POST remains its resend action.
|
|
2859
|
+
if email_gated_registration?
|
|
2860
|
+
lines.push(
|
|
2861
|
+
%(get "verify_email", to: "registrations/email_verifications#show"),
|
|
2862
|
+
%(patch "verify_email", to: "registrations/email_verifications#update"),
|
|
2863
|
+
%(get "verify_email/new", to: "registrations/email_verifications#new", as: :new_verify_email),
|
|
2864
|
+
%(post "verify_email", to: "registrations/email_verifications#create")
|
|
2865
|
+
)
|
|
2866
|
+
end
|
|
2867
|
+
lines << %(get "verify_email_change", to: "email_verifications#show") if email_change_verification?
|
|
2868
|
+
lines << %(patch "verify_email_change", to: "email_verifications#update") if email_change_verification?
|
|
2869
|
+
return "" if lines.empty?
|
|
2870
|
+
|
|
2871
|
+
lines.map { " #{it}\n" }.join.prepend("\n")
|
|
2872
|
+
end
|
|
2873
|
+
|
|
2874
|
+
# Where a phone-gated sign-up finishes. Unauthenticated by construction — the
|
|
2875
|
+
# visitor has no session yet, which is the whole point.
|
|
2876
|
+
def phone_verification_routes
|
|
2877
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2878
|
+
|
|
2879
|
+
get "verify_phone", to: "registrations/phone_verifications#new"
|
|
2880
|
+
post "verify_phone", to: "registrations/phone_verifications#create"
|
|
2881
|
+
patch "verify_phone", to: "registrations/phone_verifications#update"
|
|
2882
|
+
RUBY
|
|
2883
|
+
end
|
|
2884
|
+
|
|
2885
|
+
def magic_link_routes
|
|
2886
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2887
|
+
|
|
2888
|
+
get "magic_link", to: "magic_link#edit"
|
|
2889
|
+
patch "magic_link", to: "magic_link#update"
|
|
2890
|
+
get "magic_link/new", to: "magic_link#new", as: :new_magic_link
|
|
2891
|
+
post "magic_link", to: "magic_link#create"
|
|
2892
|
+
RUBY
|
|
2893
|
+
end
|
|
2894
|
+
|
|
2895
|
+
# Passwordless sign-in by texted code. Two steps, two paths: the request form
|
|
2896
|
+
# takes a number, the code form takes the six digits. Both unauthenticated by
|
|
2897
|
+
# construction — there is no session until the code is redeemed — which is why
|
|
2898
|
+
# what links them is a signed token in the session rather than a URL param.
|
|
2899
|
+
def sms_code_routes
|
|
2900
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2901
|
+
|
|
2902
|
+
get "sms_sign_in", to: "sms_sessions#new"
|
|
2903
|
+
post "sms_sign_in", to: "sms_sessions#create"
|
|
2904
|
+
get "sms_sign_in/code", to: "sms_sessions#edit", as: :sms_sign_in_code
|
|
2905
|
+
patch "sms_sign_in/code", to: "sms_sessions#update"
|
|
2906
|
+
RUBY
|
|
2907
|
+
end
|
|
2908
|
+
|
|
2909
|
+
# Passwordless passkey sign-in (the primary door). #new returns the assertion
|
|
2910
|
+
# challenge as JSON; #create verifies the signed assertion and starts the
|
|
2911
|
+
# session. Lives under the users scope like the other doors.
|
|
2912
|
+
def passkey_routes
|
|
2913
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2914
|
+
|
|
2915
|
+
namespace :passkeys do
|
|
2916
|
+
resource :session, only: [ :new, :create ]
|
|
2917
|
+
end
|
|
2918
|
+
RUBY
|
|
2919
|
+
end
|
|
2920
|
+
|
|
2921
|
+
# Initial credentials belong to the pending registration, before any session
|
|
2922
|
+
# exists. Emit only the endpoints this build can actually reach.
|
|
2923
|
+
def credential_enrollment_routes
|
|
2924
|
+
lines = []
|
|
2925
|
+
lines << %(get "finish_setup", to: "credential_enrollments#show") if registration_credential_page?
|
|
2926
|
+
if pre_account_password_enrollment?
|
|
2927
|
+
lines << %(get "registration/password", to: "credential_enrollments/passwords#new", as: :new_registration_password)
|
|
2928
|
+
lines << %(post "registration/password", to: "credential_enrollments/passwords#create", as: :registration_password)
|
|
2929
|
+
end
|
|
2930
|
+
if pre_account_passkey_enrollment?
|
|
2931
|
+
lines << %(get "registration/passkey", to: "credential_enrollments/passkeys#new", as: :new_registration_passkey)
|
|
2932
|
+
lines << %(post "registration/passkey", to: "credential_enrollments/passkeys#create", as: :registration_passkey)
|
|
2933
|
+
end
|
|
2934
|
+
lines.map { " #{it}\n" }.join.prepend("\n")
|
|
2935
|
+
end
|
|
2936
|
+
|
|
2937
|
+
# Manage your own passkeys (list / enroll / rename / remove), separate from the
|
|
2938
|
+
# MFA security-keys page.
|
|
2939
|
+
def passkey_profile_routes
|
|
2940
|
+
<<~RUBY
|
|
2941
|
+
resources :passkeys, only: [ :index, :new, :create, :edit, :update, :destroy ]
|
|
2942
|
+
RUBY
|
|
2943
|
+
end
|
|
2944
|
+
|
|
2945
|
+
# List and unlink your provider logins. There is no :new and no :create — linking
|
|
2946
|
+
# runs the same OmniAuth ceremony signing in does, so it goes out through
|
|
2947
|
+
# /auth/:provider and comes back to the callback, which links it because a
|
|
2948
|
+
# session is already open. The index just renders the buttons that start it.
|
|
2949
|
+
def omniauth_profile_routes
|
|
2950
|
+
<<~RUBY
|
|
2951
|
+
resources :omniauth_identities, path: "connected-accounts", only: [ :index, :destroy ]
|
|
2952
|
+
RUBY
|
|
2953
|
+
end
|
|
2954
|
+
|
|
2955
|
+
def mfa_challenge_routes
|
|
2956
|
+
<<~RUBY.gsub(/^/, " ")
|
|
2957
|
+
|
|
2958
|
+
namespace :multi_factor_authentication, path: "mfa", as: :mfa do
|
|
2959
|
+
namespace :challenge do#{totp? ? "\n resource :totp, only: [ :new, :create ]" : ""}#{sms_second_factor? ? "\n resource :sms, only: [ :new, :create, :update ]" : ""}
|
|
2960
|
+
resource :recovery_codes, only: [ :new, :create ]#{security_keys? ? "\n resource :security_keys, only: [ :new, :create ]" : ""}
|
|
2961
|
+
end
|
|
2962
|
+
end
|
|
2963
|
+
RUBY
|
|
2964
|
+
end
|
|
2965
|
+
|
|
2966
|
+
def mfa_profile_routes
|
|
2967
|
+
<<~RUBY
|
|
2968
|
+
namespace :multi_factor_authentication, path: "mfa", as: :mfa do
|
|
2969
|
+
#{mfa_profile_authenticator_routes}#{mfa_profile_sms_routes} resources :recovery_codes, only: [ :index, :create ] do
|
|
2970
|
+
post :complete, on: :collection
|
|
2971
|
+
end
|
|
2972
|
+
#{mfa_profile_security_key_routes}end
|
|
2973
|
+
RUBY
|
|
2974
|
+
end
|
|
2975
|
+
|
|
2976
|
+
def mfa_profile_authenticator_routes
|
|
2977
|
+
return "" unless totp?
|
|
2978
|
+
|
|
2979
|
+
" resource :authenticator, only: [ :new, :show, :create, :destroy ]\n"
|
|
2980
|
+
end
|
|
2981
|
+
|
|
2982
|
+
def mfa_profile_sms_routes
|
|
2983
|
+
return "" unless sms_second_factor?
|
|
2984
|
+
|
|
2985
|
+
" resource :sms, only: [ :new, :show, :create, :update, :destroy ]\n"
|
|
2986
|
+
end
|
|
2987
|
+
|
|
2988
|
+
def mfa_profile_security_key_routes
|
|
2989
|
+
return "" unless security_keys?
|
|
2990
|
+
|
|
2991
|
+
" resources :security_keys\n"
|
|
2992
|
+
end
|
|
2993
|
+
|
|
2994
|
+
def team_routes
|
|
2995
|
+
routes = ["resources :teams, only: [ :index, :new, :create ]"]
|
|
2996
|
+
routes << "resource :active_team, only: :create" if session_carries_team?
|
|
2997
|
+
routes << <<~RUBY.chomp if scope_teams?
|
|
2998
|
+
scope ":team_id", as: :team, constraints: { team_id: #{team_id_constraint} } do
|
|
2999
|
+
root to: "teams#show"
|
|
3000
|
+
end
|
|
3001
|
+
RUBY
|
|
3002
|
+
|
|
3003
|
+
"\n#{routes.join("\n")}\n"
|
|
3004
|
+
end
|
|
3005
|
+
|
|
3006
|
+
def admin_routes
|
|
3007
|
+
impersonation_routes =
|
|
3008
|
+
if impersonatable?
|
|
3009
|
+
<<~RUBY.gsub(/^/, " ")
|
|
3010
|
+
post "users/:id/impersonate", to: "users#impersonate", as: "impersonate_user"
|
|
3011
|
+
RUBY
|
|
3012
|
+
else
|
|
3013
|
+
""
|
|
3014
|
+
end
|
|
3015
|
+
|
|
3016
|
+
# Stopping sits outside the admin namespace on purpose — see
|
|
3017
|
+
# ImpersonationsController. Starting is an admin action; stopping isn't.
|
|
3018
|
+
stop_impersonating_route = impersonatable? ? %(\nresource :impersonation, only: [ :destroy ]\n) : ""
|
|
3019
|
+
|
|
3020
|
+
member_posts = []
|
|
3021
|
+
member_posts << "reset_second_factor" if second_factor?
|
|
3022
|
+
# Undeadbolt only: deadbolting is the failed-attempt counter's job, and an admin who
|
|
3023
|
+
# wants to shut an account off reaches for a ban.
|
|
3024
|
+
member_posts << "undeadbolt" if deadboltable?
|
|
3025
|
+
member_posts += %w[ban unban] if bannable?
|
|
3026
|
+
member_actions = member_posts.map { |a| "\n post :#{a}, on: :member" }.join
|
|
3027
|
+
|
|
3028
|
+
<<~RUBY
|
|
3029
|
+
|
|
3030
|
+
namespace :admin do
|
|
3031
|
+
resources :users, only: [ :index, :show, :destroy ] do
|
|
3032
|
+
delete :sessions, on: :member, action: :destroy_all_sessions#{member_actions}
|
|
3033
|
+
end
|
|
3034
|
+
resources :sessions, only: [ :index, :destroy ]
|
|
3035
|
+
#{impersonation_routes} root to: "dashboard#index"
|
|
3036
|
+
end
|
|
3037
|
+
RUBY
|
|
3038
|
+
.concat(stop_impersonating_route)
|
|
3039
|
+
end
|
|
3040
|
+
end
|