@adonis-agora/authkit-server 0.34.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.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/build/assets/grafana/authkit-dashboard.json +118 -0
- package/build/commands/clients_create.d.ts +27 -0
- package/build/commands/clients_create.js +121 -0
- package/build/commands/commands.json +292 -0
- package/build/commands/configure.d.ts +2 -0
- package/build/commands/configure.js +45 -0
- package/build/commands/doctor.d.ts +10 -0
- package/build/commands/doctor.js +113 -0
- package/build/commands/eject.d.ts +11 -0
- package/build/commands/eject.js +96 -0
- package/build/commands/expire_scan.d.ts +28 -0
- package/build/commands/expire_scan.js +92 -0
- package/build/commands/import_users.d.ts +17 -0
- package/build/commands/import_users.js +80 -0
- package/build/commands/keys_rotate.d.ts +21 -0
- package/build/commands/keys_rotate.js +126 -0
- package/build/commands/main.d.ts +12 -0
- package/build/commands/main.js +38 -0
- package/build/commands/settings_get.d.ts +18 -0
- package/build/commands/settings_get.js +43 -0
- package/build/commands/settings_list.d.ts +17 -0
- package/build/commands/settings_list.js +39 -0
- package/build/commands/settings_set.d.ts +20 -0
- package/build/commands/settings_set.js +55 -0
- package/build/commands/settings_unset.d.ts +18 -0
- package/build/commands/settings_unset.js +46 -0
- package/build/commands/ui_preset.d.ts +4 -0
- package/build/commands/ui_preset.js +32 -0
- package/build/database/migrations/make_authkit_oidc_table.d.ts +6 -0
- package/build/database/migrations/make_authkit_oidc_table.js +19 -0
- package/build/host/ui/admin.html +29 -0
- package/build/host/views/account/apps.edge +59 -0
- package/build/host/views/account/confirm.edge +70 -0
- package/build/host/views/account/email-confirmed.edge +16 -0
- package/build/host/views/account/login.edge +33 -0
- package/build/host/views/account/mfa.edge +152 -0
- package/build/host/views/account/orgs.edge +58 -0
- package/build/host/views/account/security.edge +215 -0
- package/build/host/views/account/tokens.edge +75 -0
- package/build/host/views/consent.edge +20 -0
- package/build/host/views/forgot.edge +43 -0
- package/build/host/views/login.edge +251 -0
- package/build/host/views/maintenance.edge +32 -0
- package/build/host/views/mfa-challenge.edge +107 -0
- package/build/host/views/otp-unlock.edge +40 -0
- package/build/host/views/partials/styles.edge +2 -0
- package/build/host/views/reset.edge +30 -0
- package/build/host/views/signup.edge +62 -0
- package/build/host/views/verify-email.edge +17 -0
- package/build/index.d.ts +115 -0
- package/build/index.js +76 -0
- package/build/password/common_passwords.txt +10000 -0
- package/build/providers/authkit_server_provider.d.ts +27 -0
- package/build/providers/authkit_server_provider.js +242 -0
- package/build/src/accounts/account_store.d.ts +578 -0
- package/build/src/accounts/account_store.js +60 -0
- package/build/src/accounts/lucid_account_store.d.ts +156 -0
- package/build/src/accounts/lucid_account_store.js +294 -0
- package/build/src/accounts/lucid_store/core.d.ts +9 -0
- package/build/src/accounts/lucid_store/core.js +306 -0
- package/build/src/accounts/lucid_store/mfa.d.ts +15 -0
- package/build/src/accounts/lucid_store/mfa.js +108 -0
- package/build/src/accounts/lucid_store/organizations.d.ts +13 -0
- package/build/src/accounts/lucid_store/organizations.js +294 -0
- package/build/src/accounts/lucid_store/password_hygiene.d.ts +20 -0
- package/build/src/accounts/lucid_store/password_hygiene.js +130 -0
- package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
- package/build/src/accounts/lucid_store/provider_identity.js +59 -0
- package/build/src/accounts/lucid_store/shared.d.ts +113 -0
- package/build/src/accounts/lucid_store/shared.js +130 -0
- package/build/src/accounts/lucid_store/status_profile.d.ts +34 -0
- package/build/src/accounts/lucid_store/status_profile.js +100 -0
- package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
- package/build/src/accounts/lucid_store/webauthn.js +140 -0
- package/build/src/accounts/lucid_stores.d.ts +45 -0
- package/build/src/accounts/lucid_stores.js +30 -0
- package/build/src/adapters/adapter_contract.d.ts +37 -0
- package/build/src/adapters/adapter_contract.js +1 -0
- package/build/src/adapters/database_adapter.d.ts +21 -0
- package/build/src/adapters/database_adapter.js +79 -0
- package/build/src/adapters/factory.d.ts +30 -0
- package/build/src/adapters/factory.js +43 -0
- package/build/src/adapters/redis_adapter.d.ts +24 -0
- package/build/src/adapters/redis_adapter.js +122 -0
- package/build/src/audit/audit_sink.d.ts +63 -0
- package/build/src/audit/audit_sink.js +1 -0
- package/build/src/audit/lucid_audit_sink.d.ts +10 -0
- package/build/src/audit/lucid_audit_sink.js +99 -0
- package/build/src/commands/expire_scan_command.d.ts +60 -0
- package/build/src/commands/expire_scan_command.js +229 -0
- package/build/src/commands/import_clients.d.ts +40 -0
- package/build/src/commands/import_clients.js +79 -0
- package/build/src/commands/import_users.d.ts +51 -0
- package/build/src/commands/import_users.js +94 -0
- package/build/src/commands/resolve_config.d.ts +13 -0
- package/build/src/commands/resolve_config.js +18 -0
- package/build/src/commands/settings_commands.d.ts +47 -0
- package/build/src/commands/settings_commands.js +270 -0
- package/build/src/controllers/oidc_callback_controller.d.ts +22 -0
- package/build/src/controllers/oidc_callback_controller.js +33 -0
- package/build/src/define_config.d.ts +772 -0
- package/build/src/define_config.js +351 -0
- package/build/src/doctor/checks.d.ts +197 -0
- package/build/src/doctor/checks.js +858 -0
- package/build/src/events/dispatcher.d.ts +45 -0
- package/build/src/events/dispatcher.js +117 -0
- package/build/src/host/account_api/account_api_controller.d.ts +245 -0
- package/build/src/host/account_api/account_api_controller.js +827 -0
- package/build/src/host/account_deletion_ops.d.ts +80 -0
- package/build/src/host/account_deletion_ops.js +118 -0
- package/build/src/host/account_deletion_service.d.ts +63 -0
- package/build/src/host/account_deletion_service.js +133 -0
- package/build/src/host/account_export_service.d.ts +76 -0
- package/build/src/host/account_export_service.js +140 -0
- package/build/src/host/account_home.d.ts +11 -0
- package/build/src/host/account_home.js +11 -0
- package/build/src/host/account_lockout.d.ts +86 -0
- package/build/src/host/account_lockout.js +185 -0
- package/build/src/host/active_org_cookie.d.ts +28 -0
- package/build/src/host/active_org_cookie.js +47 -0
- package/build/src/host/admin_api/admin_api_guard.d.ts +16 -0
- package/build/src/host/admin_api/admin_api_guard.js +59 -0
- package/build/src/host/admin_api/admin_orgs_service.d.ts +125 -0
- package/build/src/host/admin_api/admin_orgs_service.js +324 -0
- package/build/src/host/admin_api/admin_users_service.d.ts +163 -0
- package/build/src/host/admin_api/admin_users_service.js +308 -0
- package/build/src/host/admin_api/api_clients_controller.d.ts +54 -0
- package/build/src/host/admin_api/api_clients_controller.js +89 -0
- package/build/src/host/admin_api/api_keys_controller.d.ts +19 -0
- package/build/src/host/admin_api/api_keys_controller.js +36 -0
- package/build/src/host/admin_api/api_misc_controller.d.ts +19 -0
- package/build/src/host/admin_api/api_misc_controller.js +44 -0
- package/build/src/host/admin_api/api_orgs_controller.d.ts +108 -0
- package/build/src/host/admin_api/api_orgs_controller.js +189 -0
- package/build/src/host/admin_api/api_settings_controller.d.ts +55 -0
- package/build/src/host/admin_api/api_settings_controller.js +134 -0
- package/build/src/host/admin_api/api_users_controller.d.ts +108 -0
- package/build/src/host/admin_api/api_users_controller.js +187 -0
- package/build/src/host/admin_api/dto.d.ts +131 -0
- package/build/src/host/admin_api/dto.js +122 -0
- package/build/src/host/admin_api/token_verify_service.d.ts +34 -0
- package/build/src/host/admin_api/token_verify_service.js +74 -0
- package/build/src/host/admin_clients_service.d.ts +88 -0
- package/build/src/host/admin_clients_service.js +181 -0
- package/build/src/host/admin_console/admin_shell_controller.d.ts +27 -0
- package/build/src/host/admin_console/admin_shell_controller.js +167 -0
- package/build/src/host/admin_console/console_audit_controller.d.ts +17 -0
- package/build/src/host/admin_console/console_audit_controller.js +25 -0
- package/build/src/host/admin_console/console_clients_controller.d.ts +53 -0
- package/build/src/host/admin_console/console_clients_controller.js +102 -0
- package/build/src/host/admin_console/console_impersonation_controller.d.ts +23 -0
- package/build/src/host/admin_console/console_impersonation_controller.js +45 -0
- package/build/src/host/admin_console/console_keys_controller.d.ts +24 -0
- package/build/src/host/admin_console/console_keys_controller.js +41 -0
- package/build/src/host/admin_console/console_orgs_controller.d.ts +96 -0
- package/build/src/host/admin_console/console_orgs_controller.js +238 -0
- package/build/src/host/admin_console/console_overview_controller.d.ts +27 -0
- package/build/src/host/admin_console/console_overview_controller.js +44 -0
- package/build/src/host/admin_console/console_roles_controller.d.ts +31 -0
- package/build/src/host/admin_console/console_roles_controller.js +108 -0
- package/build/src/host/admin_console/console_sessions_controller.d.ts +91 -0
- package/build/src/host/admin_console/console_sessions_controller.js +125 -0
- package/build/src/host/admin_console/console_settings_controller.d.ts +47 -0
- package/build/src/host/admin_console/console_settings_controller.js +115 -0
- package/build/src/host/admin_console/console_users_controller.d.ts +110 -0
- package/build/src/host/admin_console/console_users_controller.js +212 -0
- package/build/src/host/admin_prefix.d.ts +50 -0
- package/build/src/host/admin_prefix.js +77 -0
- package/build/src/host/admin_sessions_service.d.ts +119 -0
- package/build/src/host/admin_sessions_service.js +284 -0
- package/build/src/host/admin_stats_service.d.ts +44 -0
- package/build/src/host/admin_stats_service.js +130 -0
- package/build/src/host/admin_validators.d.ts +247 -0
- package/build/src/host/admin_validators.js +197 -0
- package/build/src/host/augmentations.d.ts +12 -0
- package/build/src/host/augmentations.js +1 -0
- package/build/src/host/auth_host_config.d.ts +22 -0
- package/build/src/host/auth_host_config.js +13 -0
- package/build/src/host/avatar_storage.d.ts +68 -0
- package/build/src/host/avatar_storage.js +166 -0
- package/build/src/host/bot_protection.d.ts +187 -0
- package/build/src/host/bot_protection.js +132 -0
- package/build/src/host/branding.d.ts +19 -0
- package/build/src/host/branding.js +8 -0
- package/build/src/host/config_locks.d.ts +38 -0
- package/build/src/host/config_locks.js +70 -0
- package/build/src/host/console_session.d.ts +27 -0
- package/build/src/host/console_session.js +36 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +15 -0
- package/build/src/host/controllers/account_apps_controller.js +61 -0
- package/build/src/host/controllers/account_confirm_controller.d.ts +24 -0
- package/build/src/host/controllers/account_confirm_controller.js +144 -0
- package/build/src/host/controllers/account_mfa_controller.d.ts +28 -0
- package/build/src/host/controllers/account_mfa_controller.js +208 -0
- package/build/src/host/controllers/account_orgs_controller.d.ts +80 -0
- package/build/src/host/controllers/account_orgs_controller.js +401 -0
- package/build/src/host/controllers/account_security_controller.d.ts +43 -0
- package/build/src/host/controllers/account_security_controller.js +546 -0
- package/build/src/host/controllers/account_session_controller.d.ts +21 -0
- package/build/src/host/controllers/account_session_controller.js +113 -0
- package/build/src/host/controllers/account_tokens_controller.d.ts +7 -0
- package/build/src/host/controllers/account_tokens_controller.js +67 -0
- package/build/src/host/controllers/interaction_controller.d.ts +125 -0
- package/build/src/host/controllers/interaction_controller.js +981 -0
- package/build/src/host/controllers/pat_introspection_controller.d.ts +22 -0
- package/build/src/host/controllers/pat_introspection_controller.js +46 -0
- package/build/src/host/controllers/registration_controller.d.ts +18 -0
- package/build/src/host/controllers/registration_controller.js +382 -0
- package/build/src/host/controllers/social_controller.d.ts +8 -0
- package/build/src/host/controllers/social_controller.js +89 -0
- package/build/src/host/csrf.d.ts +29 -0
- package/build/src/host/csrf.js +14 -0
- package/build/src/host/default_mailer.d.ts +112 -0
- package/build/src/host/default_mailer.js +417 -0
- package/build/src/host/durable/account_deletion_workflow.d.ts +48 -0
- package/build/src/host/durable/account_deletion_workflow.js +86 -0
- package/build/src/host/durable/account_export_workflow.d.ts +68 -0
- package/build/src/host/durable/account_export_workflow.js +93 -0
- package/build/src/host/durable/index.d.ts +68 -0
- package/build/src/host/durable/index.js +77 -0
- package/build/src/host/email_templates.d.ts +39 -0
- package/build/src/host/email_templates.js +69 -0
- package/build/src/host/geo.d.ts +22 -0
- package/build/src/host/geo.js +39 -0
- package/build/src/host/i18n.d.ts +1416 -0
- package/build/src/host/i18n.js +1583 -0
- package/build/src/host/impersonation.d.ts +25 -0
- package/build/src/host/impersonation.js +31 -0
- package/build/src/host/key_rotation.d.ts +14 -0
- package/build/src/host/key_rotation.js +27 -0
- package/build/src/host/key_rotation_actions.d.ts +42 -0
- package/build/src/host/key_rotation_actions.js +27 -0
- package/build/src/host/login_attempt.d.ts +98 -0
- package/build/src/host/login_attempt.js +211 -0
- package/build/src/host/login_notify.d.ts +27 -0
- package/build/src/host/login_notify.js +142 -0
- package/build/src/host/middleware/account_auth.d.ts +7 -0
- package/build/src/host/middleware/account_auth.js +11 -0
- package/build/src/host/otp_lockout.d.ts +103 -0
- package/build/src/host/otp_lockout.js +221 -0
- package/build/src/host/rate_limit.d.ts +39 -0
- package/build/src/host/rate_limit.js +94 -0
- package/build/src/host/register_auth_host.d.ts +97 -0
- package/build/src/host/register_auth_host.js +466 -0
- package/build/src/host/renderers/edge_renderer.d.ts +12 -0
- package/build/src/host/renderers/edge_renderer.js +38 -0
- package/build/src/host/renderers/inertia_renderer.d.ts +70 -0
- package/build/src/host/renderers/inertia_renderer.js +54 -0
- package/build/src/host/runtime_settings.d.ts +181 -0
- package/build/src/host/runtime_settings.js +307 -0
- package/build/src/host/runtime_toggles.d.ts +807 -0
- package/build/src/host/runtime_toggles.js +784 -0
- package/build/src/host/security_notice_service.d.ts +36 -0
- package/build/src/host/security_notice_service.js +87 -0
- package/build/src/host/session_context.d.ts +23 -0
- package/build/src/host/session_context.js +89 -0
- package/build/src/host/sudo_mode.d.ts +56 -0
- package/build/src/host/sudo_mode.js +99 -0
- package/build/src/host/svg_chart.d.ts +15 -0
- package/build/src/host/svg_chart.js +45 -0
- package/build/src/host/trusted_device.d.ts +61 -0
- package/build/src/host/trusted_device.js +65 -0
- package/build/src/host/ui-dist/assets/index-CRWcmsex.js +137 -0
- package/build/src/host/ui-dist/assets/index-DTSmD4RU.css +1 -0
- package/build/src/host/ui-dist/index.html +17 -0
- package/build/src/host/user_agent.d.ts +18 -0
- package/build/src/host/user_agent.js +50 -0
- package/build/src/host/validators.d.ts +127 -0
- package/build/src/host/validators.js +53 -0
- package/build/src/keys/jwks_manager.d.ts +6 -0
- package/build/src/keys/jwks_manager.js +11 -0
- package/build/src/keys/keystore.d.ts +52 -0
- package/build/src/keys/keystore.js +68 -0
- package/build/src/keys/keystore_codec.d.ts +20 -0
- package/build/src/keys/keystore_codec.js +42 -0
- package/build/src/keys/keystore_crypto.d.ts +9 -0
- package/build/src/keys/keystore_crypto.js +27 -0
- package/build/src/keys/keystore_manager.d.ts +43 -0
- package/build/src/keys/keystore_manager.js +104 -0
- package/build/src/keys/keystore_vault.d.ts +113 -0
- package/build/src/keys/keystore_vault.js +265 -0
- package/build/src/mixins/json_column.d.ts +33 -0
- package/build/src/mixins/json_column.js +38 -0
- package/build/src/mixins/with_audit_log.d.ts +19 -0
- package/build/src/mixins/with_audit_log.js +39 -0
- package/build/src/mixins/with_auth_user.d.ts +24 -0
- package/build/src/mixins/with_auth_user.js +46 -0
- package/build/src/mixins/with_credentials.d.ts +20 -0
- package/build/src/mixins/with_credentials.js +29 -0
- package/build/src/mixins/with_mfa.d.ts +35 -0
- package/build/src/mixins/with_mfa.js +21 -0
- package/build/src/mixins/with_personal_access_token.d.ts +19 -0
- package/build/src/mixins/with_personal_access_token.js +42 -0
- package/build/src/mixins/with_provider_identity.d.ts +20 -0
- package/build/src/mixins/with_provider_identity.js +32 -0
- package/build/src/mixins/with_webauthn_credential.d.ts +37 -0
- package/build/src/mixins/with_webauthn_credential.js +46 -0
- package/build/src/observability/diagnostics_bridge.d.ts +6 -0
- package/build/src/observability/diagnostics_bridge.js +27 -0
- package/build/src/observability/metrics_controller.d.ts +5 -0
- package/build/src/observability/metrics_controller.js +24 -0
- package/build/src/observability/metrics_service.d.ts +2 -0
- package/build/src/observability/metrics_service.js +7 -0
- package/build/src/observability/otel_recorder.d.ts +10 -0
- package/build/src/observability/otel_recorder.js +59 -0
- package/build/src/observability/telescope/data_providers.d.ts +24 -0
- package/build/src/observability/telescope/data_providers.js +235 -0
- package/build/src/observability/telescope/extension.d.ts +29 -0
- package/build/src/observability/telescope/extension.js +167 -0
- package/build/src/observability/telescope/index.d.ts +8 -0
- package/build/src/observability/telescope/index.js +8 -0
- package/build/src/observability/wire_provider_events.d.ts +12 -0
- package/build/src/observability/wire_provider_events.js +30 -0
- package/build/src/password/common_passwords.d.ts +31 -0
- package/build/src/password/common_passwords.js +73 -0
- package/build/src/password/password_manager.d.ts +163 -0
- package/build/src/password/password_manager.js +191 -0
- package/build/src/password/policy.d.ts +71 -0
- package/build/src/password/policy.js +55 -0
- package/build/src/password/pwned.d.ts +36 -0
- package/build/src/password/pwned.js +52 -0
- package/build/src/pat/lucid_pat_store.d.ts +6 -0
- package/build/src/pat/lucid_pat_store.js +62 -0
- package/build/src/pat/pat_store.d.ts +31 -0
- package/build/src/pat/pat_store.js +1 -0
- package/build/src/pat/pat_tokens.d.ts +4 -0
- package/build/src/pat/pat_tokens.js +9 -0
- package/build/src/provider/build_provider.d.ts +45 -0
- package/build/src/provider/build_provider.js +219 -0
- package/build/src/provider/device_sources.d.ts +6 -0
- package/build/src/provider/device_sources.js +65 -0
- package/build/src/provider/interaction_actions.d.ts +36 -0
- package/build/src/provider/interaction_actions.js +55 -0
- package/build/src/provider/key_rotation_scheduler.d.ts +28 -0
- package/build/src/provider/key_rotation_scheduler.js +40 -0
- package/build/src/provider/keystore_reload.d.ts +21 -0
- package/build/src/provider/keystore_reload.js +39 -0
- package/build/src/provider/logout_sources.d.ts +13 -0
- package/build/src/provider/logout_sources.js +64 -0
- package/build/src/provider/oidc_service.d.ts +81 -0
- package/build/src/provider/oidc_service.js +254 -0
- package/build/src/provider/single_flight_lock.d.ts +18 -0
- package/build/src/provider/single_flight_lock.js +31 -0
- package/build/src/provider/token_exchange.d.ts +21 -0
- package/build/src/provider/token_exchange.js +124 -0
- package/build/src/register_routes.d.ts +16 -0
- package/build/src/register_routes.js +21 -0
- package/build/src/schema/ensure.d.ts +41 -0
- package/build/src/schema/ensure.js +262 -0
- package/build/stubs/config/authkit.stub +20 -0
- package/build/stubs/config/authkit_react.stub +52 -0
- package/build/stubs/main.d.ts +1 -0
- package/build/stubs/main.js +2 -0
- package/build/stubs/models/auth_user.stub +22 -0
- package/build/stubs/ui/edge/views/consent.edge +13 -0
- package/build/stubs/ui/edge/views/login.edge +19 -0
- package/build/stubs/ui/react/components/auth_shell.tsx +67 -0
- package/build/stubs/ui/react/pages/account/login.tsx +56 -0
- package/build/stubs/ui/react/pages/account/mfa.tsx +132 -0
- package/build/stubs/ui/react/pages/account/tokens.tsx +88 -0
- package/build/stubs/ui/react/pages/consent.tsx +39 -0
- package/build/stubs/ui/react/pages/forgot.tsx +44 -0
- package/build/stubs/ui/react/pages/login.tsx +171 -0
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
- package/build/stubs/ui/react/pages/reset.tsx +58 -0
- package/build/stubs/ui/react/pages/signup.tsx +78 -0
- package/build/stubs/ui/react/pages/verify-email.tsx +24 -0
- package/build/types.d.ts +7 -0
- package/build/types.js +1 -0
- package/package.json +147 -0
- package/stubs/config/authkit.stub +20 -0
- package/stubs/config/authkit_react.stub +52 -0
- package/stubs/main.ts +2 -0
- package/stubs/models/auth_user.stub +22 -0
- package/stubs/ui/edge/views/consent.edge +13 -0
- package/stubs/ui/edge/views/login.edge +19 -0
- package/stubs/ui/react/components/auth_shell.tsx +67 -0
- package/stubs/ui/react/pages/account/login.tsx +56 -0
- package/stubs/ui/react/pages/account/mfa.tsx +132 -0
- package/stubs/ui/react/pages/account/tokens.tsx +88 -0
- package/stubs/ui/react/pages/consent.tsx +39 -0
- package/stubs/ui/react/pages/forgot.tsx +44 -0
- package/stubs/ui/react/pages/login.tsx +171 -0
- package/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
- package/stubs/ui/react/pages/reset.tsx +58 -0
- package/stubs/ui/react/pages/signup.tsx +78 -0
- package/stubs/ui/react/pages/verify-email.tsx +24 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import { brandFor, isFirstParty } from '../branding.js';
|
|
3
|
+
import { translate } from '../i18n.js';
|
|
4
|
+
import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
|
|
5
|
+
import { notifyLoginSuccess } from '../login_notify.js';
|
|
6
|
+
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
7
|
+
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
8
|
+
import { resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveAuthMethods, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
|
|
9
|
+
import { supportsPasskeys, supportsMagicLink } from '../../accounts/account_store.js';
|
|
10
|
+
import { sendMagicLinkEmail } from '../default_mailer.js';
|
|
11
|
+
import { TRUSTED_DEVICE_COOKIE, buildTrustedDevicePayload, isTrustedDeviceValid, } from '../trusted_device.js';
|
|
12
|
+
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
13
|
+
import { createOtpLockout, resolveEffectiveOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, } from '../otp_lockout.js';
|
|
14
|
+
import { sendOtpUnlockEmail } from '../default_mailer.js';
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort: returns a RuntimeSettings backed by the container DB, or a no-op
|
|
17
|
+
* fallback. Contrato NON-NULL preservado — é chamado 6× passando direto p/
|
|
18
|
+
* funções que exigem `SettingsCapability` non-null. Reusa a fábrica canônica
|
|
19
|
+
* `resolveRuntimeSettings` e degrada para o RuntimeSettings no-op (probe via
|
|
20
|
+
* SELECT lança → tabela ausente → config fallback) quando a resolução falha.
|
|
21
|
+
*/
|
|
22
|
+
async function getRuntimeSettings(ctx) {
|
|
23
|
+
const rs = await resolveRuntimeSettings(ctx);
|
|
24
|
+
return rs ?? new RuntimeSettings({ table: () => { throw new Error('no-op'); } });
|
|
25
|
+
}
|
|
26
|
+
const SESSION_KEY = 'authkit_login_email';
|
|
27
|
+
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
28
|
+
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
29
|
+
/** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
|
|
30
|
+
const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
|
|
31
|
+
export default class AuthInteractionController {
|
|
32
|
+
async show(ctx) {
|
|
33
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
34
|
+
const cfg = service.config;
|
|
35
|
+
const render = cfg.render;
|
|
36
|
+
const details = await service.interactions.details(ctx);
|
|
37
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
38
|
+
// Maintenance mode: verifica se é uma conta admin antes de bloquear.
|
|
39
|
+
// Contas admin continuam podendo logar para que o operador possa desligar a
|
|
40
|
+
// manutenção via console. Contas comuns veem a tela de manutenção.
|
|
41
|
+
const runtimeSettingsForMaintenance = await getRuntimeSettings(ctx);
|
|
42
|
+
const maintenance = await resolveEffectiveMaintenanceMode(runtimeSettingsForMaintenance);
|
|
43
|
+
if (maintenance.enabled) {
|
|
44
|
+
// Verifica se já há uma conta admin na sessão (já autenticado) OU
|
|
45
|
+
// se o prompt não é de login — nesse caso, permite prosseguir.
|
|
46
|
+
// Para o prompt de login: permite continuar sem bloquear; o guard real
|
|
47
|
+
// está no POST. Dessa forma o admin pode inserir email/senha normalmente.
|
|
48
|
+
// A tela de manutenção é exibida apenas para o prompt de login quando
|
|
49
|
+
// não há indicação de conta admin (antes de autenticar não sabemos a role).
|
|
50
|
+
// Decisão: exibimos a tela de manutenção SOMENTE se não for conta admin.
|
|
51
|
+
// Como não sabemos a role antes de autenticar, exibimos a tela com uma nota
|
|
52
|
+
// de que admins podem continuar — o POST real tem o guard.
|
|
53
|
+
if (details.prompt.name === 'login') {
|
|
54
|
+
return render(ctx, 'maintenance', {
|
|
55
|
+
uid: details.uid,
|
|
56
|
+
csrfToken: ctx.request.csrfToken,
|
|
57
|
+
message: maintenance.message ?? translate(cfg.messages, 'maintenance.default_message'),
|
|
58
|
+
brand,
|
|
59
|
+
adminLoginAllowed: true,
|
|
60
|
+
adminLoginNote: translate(cfg.messages, 'maintenance.admin_login_note'),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (details.prompt.name === 'consent' && isFirstParty(cfg.branding, details.params.client_id)) {
|
|
65
|
+
// Clients first-party: auto-concede o consent (pula a tela de consent).
|
|
66
|
+
// interactions.consent monta o Grant + interactionFinished e escreve o
|
|
67
|
+
// redirect de volta para o client — opera via provider.interactionDetails,
|
|
68
|
+
// independente do metodo HTTP, entao funciona no GET show().
|
|
69
|
+
return await service.interactions.consent(ctx);
|
|
70
|
+
}
|
|
71
|
+
if (details.prompt.name !== 'login') {
|
|
72
|
+
// Consent or other prompts — unchanged
|
|
73
|
+
return render(ctx, 'consent', {
|
|
74
|
+
uid: details.uid,
|
|
75
|
+
params: details.params,
|
|
76
|
+
csrfToken: ctx.request.csrfToken,
|
|
77
|
+
brand,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
81
|
+
// Resolve auth methods (capabilities: magic link, passkey-first, social providers).
|
|
82
|
+
// Capabilities are account-independent at this stage for the identifier step.
|
|
83
|
+
const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
|
|
84
|
+
const configuredSocialProviders = cfg.social?.providers ?? [];
|
|
85
|
+
const authMethodsSettings = runtimeSettingsForMaintenance;
|
|
86
|
+
const authMethods = await resolveEffectiveAuthMethods(authMethodsSettings, {
|
|
87
|
+
configuredSocialProviders,
|
|
88
|
+
magicLinkCapable: magicLinkCapableConfig,
|
|
89
|
+
passkeyCapable: false, // passkey-first depends on account — resolved in step 2
|
|
90
|
+
});
|
|
91
|
+
if (!email) {
|
|
92
|
+
// Step 1: identifier (email only).
|
|
93
|
+
// Resolve registration enabled to hide "create account" link when closed.
|
|
94
|
+
const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettingsForMaintenance);
|
|
95
|
+
return render(ctx, 'login', {
|
|
96
|
+
uid: details.uid,
|
|
97
|
+
csrfToken: ctx.request.csrfToken,
|
|
98
|
+
step: 'identifier',
|
|
99
|
+
registrationEnabled,
|
|
100
|
+
brand,
|
|
101
|
+
authMethods,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
|
|
105
|
+
const acc = await cfg.accountStore.findByEmail(email);
|
|
106
|
+
const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
|
|
107
|
+
// Passwordless: magic link disponível se ligado E o store suporta E auth_methods permite.
|
|
108
|
+
// Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
|
|
109
|
+
const magicLinkAvailable = authMethods.magicLink && magicLinkCapableConfig;
|
|
110
|
+
const passkeyFirstCapable = cfg.passwordless.passkeyFirst && !!acc && (await this.hasPasskeys(cfg, acc.id));
|
|
111
|
+
const passkeyFirstAvailable = authMethods.passkey && passkeyFirstCapable;
|
|
112
|
+
// Effective bot protection: may be overridden at runtime via auth_settings.
|
|
113
|
+
const effectiveBot = await resolveEffectiveBotProtection(cfg.botProtection, await getRuntimeSettings(ctx));
|
|
114
|
+
// Session policy: resolve para exibir o checkbox "manter conectado".
|
|
115
|
+
const sessionPolicyForShow = await resolveEffectiveSessionPolicy(runtimeSettingsForMaintenance, cfg.ttl?.session ? Math.ceil(cfg.ttl.session / 3600) : undefined);
|
|
116
|
+
return render(ctx, 'login', {
|
|
117
|
+
uid: details.uid,
|
|
118
|
+
csrfToken: ctx.request.csrfToken,
|
|
119
|
+
step: 'password',
|
|
120
|
+
email,
|
|
121
|
+
account,
|
|
122
|
+
brand,
|
|
123
|
+
magicLinkAvailable,
|
|
124
|
+
passkeyFirstAvailable,
|
|
125
|
+
botProtection: effectiveBot?.on.includes('login') ? effectiveBot.widget : undefined,
|
|
126
|
+
authMethods,
|
|
127
|
+
rememberEnabled: sessionPolicyForShow.rememberEnabled,
|
|
128
|
+
rememberDays: sessionPolicyForShow.rememberDays,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* POST /auth/interaction/:uid/identifier
|
|
133
|
+
* Step 1: receive email, store in session, redirect to step 2.
|
|
134
|
+
* ENUMERATION-SAFE: always advances regardless of whether the email exists.
|
|
135
|
+
*/
|
|
136
|
+
async identifier(ctx) {
|
|
137
|
+
const { email } = ctx.request.only(['email']);
|
|
138
|
+
ctx.session.put(SESSION_KEY, email);
|
|
139
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* POST /auth/interaction/:uid/login
|
|
143
|
+
* Step 2: password submit. Reads email from session (never from form).
|
|
144
|
+
*/
|
|
145
|
+
async login(ctx) {
|
|
146
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
147
|
+
const cfg = service.config;
|
|
148
|
+
const render = cfg.render;
|
|
149
|
+
const details = await service.interactions.details(ctx);
|
|
150
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
151
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
152
|
+
if (!email) {
|
|
153
|
+
// Session expired or tampered — send back to step 1
|
|
154
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
155
|
+
}
|
|
156
|
+
const { password } = ctx.request.only(['password']);
|
|
157
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
158
|
+
const clientId = details.params.client_id ?? null;
|
|
159
|
+
// Resolve runtime settings (reutilizado por bot + maintenance + verifiedEmail + session_policy).
|
|
160
|
+
const runtimeSettings = await getRuntimeSettings(ctx);
|
|
161
|
+
// Maintenance mode: verifica se a conta que tenta logar é admin.
|
|
162
|
+
// Admins CONTINUAM podendo logar (senão o operador se tranca fora).
|
|
163
|
+
// A verificação de role acontece APÓS as credenciais (senha correta primeiro).
|
|
164
|
+
const maintenance = await resolveEffectiveMaintenanceMode(runtimeSettings);
|
|
165
|
+
// Effective bot protection: may be overridden at runtime via auth_settings.
|
|
166
|
+
const effectiveBotLogin = await resolveEffectiveBotProtection(cfg.botProtection, runtimeSettings);
|
|
167
|
+
// Bot protection (plugável): valida o token do widget ANTES de tocar nas
|
|
168
|
+
// credenciais. Fail-safe: erro/timeout no verify do host PERMITE o fluxo.
|
|
169
|
+
// We use a cfg-compatible object: pass effectiveBotLogin merged into cfg.
|
|
170
|
+
const cfgWithEffectiveBot = effectiveBotLogin !== cfg.botProtection
|
|
171
|
+
? { ...cfg, botProtection: effectiveBotLogin }
|
|
172
|
+
: cfg;
|
|
173
|
+
if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', { email, clientId }))) {
|
|
174
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
175
|
+
const account = found
|
|
176
|
+
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
177
|
+
: null;
|
|
178
|
+
return render(ctx, 'login', {
|
|
179
|
+
uid: ctx.request.param('uid'),
|
|
180
|
+
csrfToken: ctx.request.csrfToken,
|
|
181
|
+
step: 'password',
|
|
182
|
+
email,
|
|
183
|
+
account,
|
|
184
|
+
error: translate(cfg.messages, 'errors.bot_protection_failed'),
|
|
185
|
+
brand,
|
|
186
|
+
botProtection: effectiveBotLogin?.widget,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
|
|
190
|
+
// ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
|
|
191
|
+
// ainda. A sequência verificação + lockout + auditoria de falha é centralizada
|
|
192
|
+
// em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
|
|
193
|
+
const result = await attemptPasswordLogin(cfg, { email, password, ip, clientId, settings: runtimeSettings, logger: ctx.logger });
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
// Senha expirada: redireciona para o step de troca obrigatória.
|
|
196
|
+
if (result.passwordExpired && result.account) {
|
|
197
|
+
const expiredAcc = result.account;
|
|
198
|
+
const PASSWORD_EXPIRED_KEY = 'authkit_password_expired';
|
|
199
|
+
ctx.session.put(PASSWORD_EXPIRED_KEY, expiredAcc.id);
|
|
200
|
+
return render(ctx, 'login', {
|
|
201
|
+
uid: ctx.request.param('uid'),
|
|
202
|
+
csrfToken: ctx.request.csrfToken,
|
|
203
|
+
step: 'password_expired',
|
|
204
|
+
email,
|
|
205
|
+
account: { fullName: expiredAcc.name ?? null, globalRoles: expiredAcc.globalRoles ?? [] },
|
|
206
|
+
error: null,
|
|
207
|
+
brand,
|
|
208
|
+
botProtection: undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
212
|
+
const account = found
|
|
213
|
+
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
214
|
+
: null;
|
|
215
|
+
return render(ctx, 'login', {
|
|
216
|
+
uid: ctx.request.param('uid'),
|
|
217
|
+
csrfToken: ctx.request.csrfToken,
|
|
218
|
+
step: 'password',
|
|
219
|
+
email,
|
|
220
|
+
account,
|
|
221
|
+
error: result.locked
|
|
222
|
+
? translate(cfg.messages, 'errors.account_locked', {
|
|
223
|
+
seconds: result.retryAfterSec ?? 0,
|
|
224
|
+
})
|
|
225
|
+
: result.disabled
|
|
226
|
+
? translate(cfg.messages, 'errors.account_disabled')
|
|
227
|
+
: result.unverified
|
|
228
|
+
? translate(cfg.messages, 'errors.email_unverified')
|
|
229
|
+
: result.accountExpired
|
|
230
|
+
? translate(cfg.messages, 'errors.account_expired')
|
|
231
|
+
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
232
|
+
brand,
|
|
233
|
+
botProtection: effectiveBotLogin?.on.includes('login') ? effectiveBotLogin.widget : undefined,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const acc = result.account;
|
|
237
|
+
// Maintenance mode guard: credenciais válidas, mas manutenção ativa.
|
|
238
|
+
// Contas admin (role em cfg.admin.roles) CONTINUAM podendo logar.
|
|
239
|
+
// Contas comuns são bloqueadas com a tela de manutenção.
|
|
240
|
+
if (maintenance.enabled) {
|
|
241
|
+
const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
|
|
242
|
+
const accountRoles = acc.globalRoles ?? [];
|
|
243
|
+
const isAdmin = adminRoles.some((r) => accountRoles.includes(r));
|
|
244
|
+
if (!isAdmin) {
|
|
245
|
+
return render(ctx, 'maintenance', {
|
|
246
|
+
uid: details.uid,
|
|
247
|
+
csrfToken: ctx.request.csrfToken,
|
|
248
|
+
message: maintenance.message ?? translate(cfg.messages, 'maintenance.default_message'),
|
|
249
|
+
brand,
|
|
250
|
+
adminLoginAllowed: true,
|
|
251
|
+
adminLoginNote: translate(cfg.messages, 'maintenance.admin_login_note'),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// Admin: prossegue normalmente (sem bloqueio).
|
|
255
|
+
}
|
|
256
|
+
// Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
|
|
257
|
+
// solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
|
|
258
|
+
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
259
|
+
// MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
|
|
260
|
+
// via acr. Não finaliza a interaction agora — guarda o accountId pendente.
|
|
261
|
+
const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
|
|
262
|
+
if (mfa.enabled || mfaRequired) {
|
|
263
|
+
if (mfaRequired && !mfa.enabled) {
|
|
264
|
+
// Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
|
|
265
|
+
// com a instrução de configurar MFA no console (não há 2º fator a desafiar).
|
|
266
|
+
return render(ctx, 'mfa-challenge', {
|
|
267
|
+
uid: ctx.request.param('uid'),
|
|
268
|
+
csrfToken: ctx.request.csrfToken,
|
|
269
|
+
brand,
|
|
270
|
+
passkeyAvailable: false,
|
|
271
|
+
error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
|
|
272
|
+
noEnrollment: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
|
|
276
|
+
// o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
|
|
277
|
+
// válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
|
|
278
|
+
if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
|
|
279
|
+
const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
|
|
280
|
+
if (trusted) {
|
|
281
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
|
|
282
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
283
|
+
accountId: acc.id,
|
|
284
|
+
email,
|
|
285
|
+
ip,
|
|
286
|
+
clientId,
|
|
287
|
+
trustedDevice: true,
|
|
288
|
+
});
|
|
289
|
+
ctx.session.forget(SESSION_KEY);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
294
|
+
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
295
|
+
// tem ao menos uma credencial registrada.
|
|
296
|
+
const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
|
|
297
|
+
return render(ctx, 'mfa-challenge', {
|
|
298
|
+
uid: ctx.request.param('uid'),
|
|
299
|
+
csrfToken: ctx.request.csrfToken,
|
|
300
|
+
brand,
|
|
301
|
+
passkeyAvailable,
|
|
302
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
303
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
307
|
+
// Resolve session_policy para remember-me e single-session.
|
|
308
|
+
const sessionPolicy = await resolveEffectiveSessionPolicy(runtimeSettings, cfg.ttl?.session ? Math.ceil(cfg.ttl.session / 3600) : undefined);
|
|
309
|
+
// remember-me: lê o checkbox do form. Checkbox HTML ausente/desmarcado = sem valor.
|
|
310
|
+
// Quando rememberEnabled=false, ignora o checkbox (sessão sempre transiente).
|
|
311
|
+
const rememberChecked = ctx.request.input('remember');
|
|
312
|
+
const rememberOn = rememberChecked === 'on' || rememberChecked === '1' || rememberChecked === 'true' || rememberChecked === true;
|
|
313
|
+
const remember = sessionPolicy.rememberEnabled ? rememberOn : false;
|
|
314
|
+
// single-session: lista sessões ANTES do login para saber quais existem.
|
|
315
|
+
// Após o completeLogin, a sessão NOVA é criada; revogamos as ANTERIORES.
|
|
316
|
+
// Obtemos os ids das sessões correntes para que possamos preservar a nova.
|
|
317
|
+
let prevSessionIds = new Set();
|
|
318
|
+
if (sessionPolicy.singleSession) {
|
|
319
|
+
const sessionsSvc = new AdminSessionsService(service);
|
|
320
|
+
if (sessionsSvc.canList) {
|
|
321
|
+
const prev = await sessionsSvc.listSessions(acc.id);
|
|
322
|
+
prevSessionIds = new Set(prev.map((s) => s.id));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
await service.interactions.completeLogin(ctx, acc.id, { remember });
|
|
326
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
|
|
327
|
+
// single-session: após o completeLogin, revoga sessões que existiam ANTES.
|
|
328
|
+
// A sessão nova foi criada pelo provider no resume — ela NÃO está em prevSessionIds.
|
|
329
|
+
if (sessionPolicy.singleSession && prevSessionIds.size > 0) {
|
|
330
|
+
const sessionsSvc = new AdminSessionsService(service);
|
|
331
|
+
if (sessionsSvc.canList) {
|
|
332
|
+
const current = await sessionsSvc.listSessions(acc.id);
|
|
333
|
+
const newSession = current.find((s) => !prevSessionIds.has(s.id));
|
|
334
|
+
const exceptId = newSession?.id ?? '__none__';
|
|
335
|
+
await sessionsSvc.revokeAllExcept(acc.id, exceptId);
|
|
336
|
+
await cfg.audit?.record({
|
|
337
|
+
type: 'session.single_enforced',
|
|
338
|
+
accountId: acc.id,
|
|
339
|
+
ip,
|
|
340
|
+
clientId,
|
|
341
|
+
metadata: { revokedCount: prevSessionIds.size },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Clean up the session key after a successful login.
|
|
346
|
+
ctx.session.forget(SESSION_KEY);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* POST /auth/interaction/:uid/mfa
|
|
350
|
+
* 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
|
|
351
|
+
* OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
|
|
352
|
+
*/
|
|
353
|
+
async mfaVerify(ctx) {
|
|
354
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
355
|
+
const cfg = service.config;
|
|
356
|
+
const render = cfg.render;
|
|
357
|
+
const details = await service.interactions.details(ctx);
|
|
358
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
359
|
+
const accountId = ctx.session.get(MFA_PENDING_KEY);
|
|
360
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
361
|
+
const clientId = details.params.client_id ?? null;
|
|
362
|
+
if (!accountId) {
|
|
363
|
+
// Sessão expirou/foi adulterada — volta ao início do login.
|
|
364
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
365
|
+
}
|
|
366
|
+
const { code, recoveryCode } = ctx.request.only(['code', 'recoveryCode']);
|
|
367
|
+
// Resolve OTP lockout settings (fail-safe).
|
|
368
|
+
const runtimeForOtp = await getRuntimeSettings(ctx);
|
|
369
|
+
const otpLockoutCfg = await resolveEffectiveOtpLockout(runtimeForOtp);
|
|
370
|
+
const otpLockout = createOtpLockout(otpLockoutCfg, ctx.logger);
|
|
371
|
+
// Verifica se o fator OTP está travado ANTES de tentar verificar.
|
|
372
|
+
if (otpLockoutCfg.enabled && await otpLockout.isLocked(accountId)) {
|
|
373
|
+
return render(ctx, 'mfa-challenge', {
|
|
374
|
+
uid: ctx.request.param('uid'),
|
|
375
|
+
csrfToken: ctx.request.csrfToken,
|
|
376
|
+
error: translate(cfg.messages, 'errors.otp_locked'),
|
|
377
|
+
brand,
|
|
378
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
379
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
380
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
381
|
+
otpLocked: true,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
let ok = false;
|
|
385
|
+
let usedRecovery = false;
|
|
386
|
+
if (recoveryCode) {
|
|
387
|
+
ok = (await cfg.accountStore.consumeRecoveryCode?.(accountId, recoveryCode)) ?? false;
|
|
388
|
+
usedRecovery = ok;
|
|
389
|
+
}
|
|
390
|
+
else if (code) {
|
|
391
|
+
ok = (await cfg.accountStore.verifyTotp?.(accountId, code)) ?? false;
|
|
392
|
+
}
|
|
393
|
+
if (!ok) {
|
|
394
|
+
await cfg.audit?.record({
|
|
395
|
+
type: 'login.failure',
|
|
396
|
+
accountId,
|
|
397
|
+
ip,
|
|
398
|
+
clientId,
|
|
399
|
+
metadata: { stage: 'mfa' },
|
|
400
|
+
});
|
|
401
|
+
// Registra falha OTP e verifica se deve travar.
|
|
402
|
+
const nowLocked = await otpLockout.recordFailure(accountId, { sink: cfg.audit, ip });
|
|
403
|
+
if (nowLocked) {
|
|
404
|
+
// Emite e-mail de desbloqueio (best-effort, fail-safe).
|
|
405
|
+
await this.sendOtpUnlockEmailIfAble(ctx, cfg, accountId, otpLockoutCfg.unlockTtlHours);
|
|
406
|
+
return render(ctx, 'mfa-challenge', {
|
|
407
|
+
uid: ctx.request.param('uid'),
|
|
408
|
+
csrfToken: ctx.request.csrfToken,
|
|
409
|
+
error: translate(cfg.messages, 'errors.otp_locked'),
|
|
410
|
+
brand,
|
|
411
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
412
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
413
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
414
|
+
otpLocked: true,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return render(ctx, 'mfa-challenge', {
|
|
418
|
+
uid: ctx.request.param('uid'),
|
|
419
|
+
csrfToken: ctx.request.csrfToken,
|
|
420
|
+
error: translate(cfg.messages, 'errors.invalid_code'),
|
|
421
|
+
brand,
|
|
422
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
423
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
424
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
// Código correto: zera o contador de falhas OTP.
|
|
428
|
+
await otpLockout.clearFailures(accountId);
|
|
429
|
+
// Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
|
|
430
|
+
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
431
|
+
// Finaliza a interaction para o accountId pendente.
|
|
432
|
+
ctx.session.forget(MFA_PENDING_KEY);
|
|
433
|
+
ctx.session.forget(SESSION_KEY);
|
|
434
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
435
|
+
accountId,
|
|
436
|
+
ip,
|
|
437
|
+
clientId,
|
|
438
|
+
metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
|
|
439
|
+
});
|
|
440
|
+
// Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
|
|
441
|
+
// se o client solicitou o mfaAcr nesta requisição.
|
|
442
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
446
|
+
* config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
|
|
447
|
+
*/
|
|
448
|
+
acrRequiresMfa(cfg, details) {
|
|
449
|
+
const mfaAcr = cfg.stepUp?.mfaAcr;
|
|
450
|
+
if (!mfaAcr)
|
|
451
|
+
return false;
|
|
452
|
+
const raw = details?.params?.acr_values;
|
|
453
|
+
if (typeof raw !== 'string' || !raw)
|
|
454
|
+
return false;
|
|
455
|
+
return raw.split(/\s+/).includes(mfaAcr);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
|
|
459
|
+
* foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
|
|
460
|
+
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
461
|
+
*/
|
|
462
|
+
stepUpExtra(cfg, details, method) {
|
|
463
|
+
if (!this.acrRequiresMfa(cfg, details))
|
|
464
|
+
return undefined;
|
|
465
|
+
return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
|
|
466
|
+
}
|
|
467
|
+
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
468
|
+
async hasPasskeys(cfg, accountId) {
|
|
469
|
+
if (!supportsPasskeys(cfg.accountStore))
|
|
470
|
+
return false;
|
|
471
|
+
const list = await cfg.accountStore.listPasskeys(accountId);
|
|
472
|
+
return Array.isArray(list) && list.length > 0;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Lê o cookie de dispositivo confiável (encriptado, appKey-backed) e valida que
|
|
476
|
+
* pertence a `accountId`, não expirou e é posterior ao último (re)enrollment de
|
|
477
|
+
* MFA. Step-up NÃO chama isto (força sempre o MFA). Best-effort: qualquer erro de
|
|
478
|
+
* leitura → não confiável.
|
|
479
|
+
*/
|
|
480
|
+
async checkTrustedDevice(ctx, accountId, mfaEnabledAt) {
|
|
481
|
+
try {
|
|
482
|
+
const payload = ctx.request.encryptedCookie(TRUSTED_DEVICE_COOKIE);
|
|
483
|
+
return isTrustedDeviceValid(payload, { accountId, mfaEnabledAt });
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Se o checkbox "confiar neste dispositivo" foi marcado E o mecanismo está ligado,
|
|
491
|
+
* grava o cookie encriptado de confiança para a conta (skip MFA por N dias).
|
|
492
|
+
*/
|
|
493
|
+
async maybeTrustDevice(ctx, cfg, accountId) {
|
|
494
|
+
if (!cfg.trustedDevices.enabled)
|
|
495
|
+
return;
|
|
496
|
+
const checked = ctx.request.input('trustDevice');
|
|
497
|
+
// Checkbox HTML: presente ('on'/'true'/'1') = marcado.
|
|
498
|
+
const on = checked === 'on' || checked === 'true' || checked === '1' || checked === true;
|
|
499
|
+
if (!on)
|
|
500
|
+
return;
|
|
501
|
+
const payload = buildTrustedDevicePayload(accountId, cfg.trustedDevices);
|
|
502
|
+
ctx.response.encryptedCookie(TRUSTED_DEVICE_COOKIE, payload, {
|
|
503
|
+
httpOnly: true,
|
|
504
|
+
sameSite: 'lax',
|
|
505
|
+
maxAge: cfg.trustedDevices.days * 24 * 60 * 60,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Emite o e-mail de desbloqueio OTP quando o fator fica travado.
|
|
510
|
+
* Best-effort, fail-safe: nunca lança na request.
|
|
511
|
+
*/
|
|
512
|
+
async sendOtpUnlockEmailIfAble(ctx, cfg, accountId, unlockTtlHours) {
|
|
513
|
+
try {
|
|
514
|
+
const account = await cfg.accountStore.findById(accountId);
|
|
515
|
+
if (!account?.email)
|
|
516
|
+
return;
|
|
517
|
+
// Gera o token e persiste no campo passwordResetToken (com prefixo `ou:`).
|
|
518
|
+
// Reutiliza a coluna existente sem nova migração (padrão magic-link/email-change).
|
|
519
|
+
const { raw, dbValue } = generateOtpUnlockToken();
|
|
520
|
+
const store = cfg.accountStore;
|
|
521
|
+
const row = await store.__getRawRow?.(accountId);
|
|
522
|
+
if (row) {
|
|
523
|
+
row.passwordResetToken = dbValue;
|
|
524
|
+
// Expira em unlockTtlHours horas.
|
|
525
|
+
const { DateTime } = await import('luxon');
|
|
526
|
+
row.passwordResetExpiresAt = DateTime.now().plus({ hours: unlockTtlHours });
|
|
527
|
+
await row.save();
|
|
528
|
+
}
|
|
529
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
530
|
+
const unlockUrl = `${origin}/auth/otp-unlock/${encodeURIComponent(raw)}`;
|
|
531
|
+
if (cfg.mail?.onOtpUnlock) {
|
|
532
|
+
await cfg.mail.onOtpUnlock({ email: account.email, unlockUrl, token: raw }).catch(() => { });
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
await sendOtpUnlockEmail(ctx, { email: account.email, unlockUrl }).catch(() => { });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// Best-effort: nunca propaga erro para o fluxo de login.
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* accountId para uma cerimônia de passkey no login. Prioriza o accountId pendente
|
|
544
|
+
* do MFA (passkey como 2º fator). Quando ausente e o passkey-first está ligado,
|
|
545
|
+
* resolve a conta pelo e-mail guardado na sessão (passkey ANTES da senha) — só se
|
|
546
|
+
* a conta existe E tem ao menos uma passkey.
|
|
547
|
+
*/
|
|
548
|
+
async resolvePasskeyAccountId(ctx, cfg) {
|
|
549
|
+
const pending = ctx.session.get(MFA_PENDING_KEY);
|
|
550
|
+
if (pending)
|
|
551
|
+
return pending;
|
|
552
|
+
if (!cfg.passwordless?.passkeyFirst)
|
|
553
|
+
return undefined;
|
|
554
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
555
|
+
if (!email)
|
|
556
|
+
return undefined;
|
|
557
|
+
const acc = await cfg.accountStore.findByEmail(email);
|
|
558
|
+
if (!acc)
|
|
559
|
+
return undefined;
|
|
560
|
+
return (await this.hasPasskeys(cfg, acc.id)) ? acc.id : undefined;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* POST /auth/interaction/:uid/magic
|
|
564
|
+
* Magic link: lê o e-mail da sessão (passwordless.magicLink ligado), emite um
|
|
565
|
+
* token de uso único e dispara o e-mail. SEMPRE renderiza "link enviado",
|
|
566
|
+
* independentemente de a conta existir (anti-enumeração). Throttled como o login.
|
|
567
|
+
*/
|
|
568
|
+
async magicLinkRequest(ctx) {
|
|
569
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
570
|
+
const cfg = service.config;
|
|
571
|
+
const render = cfg.render;
|
|
572
|
+
const details = await service.interactions.details(ctx);
|
|
573
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
574
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
575
|
+
const uid = ctx.request.param('uid');
|
|
576
|
+
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
577
|
+
const issued = await cfg.accountStore.issueMagicLinkToken(email);
|
|
578
|
+
if (issued) {
|
|
579
|
+
await cfg.audit?.record({
|
|
580
|
+
type: 'login.magic_link_sent',
|
|
581
|
+
accountId: issued.account.id,
|
|
582
|
+
email,
|
|
583
|
+
ip: ctx.request.ip?.() ?? null,
|
|
584
|
+
clientId: details.params.client_id ?? null,
|
|
585
|
+
});
|
|
586
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
587
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
588
|
+
if (cfg.mail?.onMagicLink) {
|
|
589
|
+
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
await sendMagicLinkEmail(ctx, { email, magicUrl });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Resposta uniforme (não vaza existência de conta).
|
|
597
|
+
return render(ctx, 'login', {
|
|
598
|
+
uid,
|
|
599
|
+
csrfToken: ctx.request.csrfToken,
|
|
600
|
+
step: 'password',
|
|
601
|
+
email,
|
|
602
|
+
account: null,
|
|
603
|
+
brand,
|
|
604
|
+
magicLinkSent: true,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* GET /auth/interaction/:uid/magic?token=...
|
|
609
|
+
* Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
|
|
610
|
+
* inválido/expirado volta ao início do login.
|
|
611
|
+
*/
|
|
612
|
+
async magicLinkConsume(ctx) {
|
|
613
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
614
|
+
const cfg = service.config;
|
|
615
|
+
const uid = ctx.request.param('uid');
|
|
616
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
617
|
+
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
618
|
+
const token = ctx.request.qs().token ?? '';
|
|
619
|
+
if (!cfg.passwordless.magicLink || !supportsMagicLink(cfg.accountStore) || !token) {
|
|
620
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
621
|
+
}
|
|
622
|
+
const acc = await cfg.accountStore.consumeMagicLinkToken(token);
|
|
623
|
+
if (!acc) {
|
|
624
|
+
await cfg.audit?.record({ type: 'login.failure', ip, clientId, metadata: { stage: 'magic_link' } });
|
|
625
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
626
|
+
}
|
|
627
|
+
// E-mail não verificado (LGPD/compliance): mesmo com o link válido, não
|
|
628
|
+
// materializa a sessão se a política exige verificação. Volta ao login com erro.
|
|
629
|
+
const magicLinkRuntimeSettings = await getRuntimeSettings(ctx);
|
|
630
|
+
if (await isEmailUnverifiedBlock(cfg, acc.id, magicLinkRuntimeSettings)) {
|
|
631
|
+
await cfg.audit?.record({
|
|
632
|
+
type: 'login.failure',
|
|
633
|
+
accountId: acc.id,
|
|
634
|
+
email: acc.email,
|
|
635
|
+
ip,
|
|
636
|
+
clientId,
|
|
637
|
+
metadata: { stage: 'magic_link', reason: 'unverified' },
|
|
638
|
+
});
|
|
639
|
+
const render = cfg.render;
|
|
640
|
+
return render(ctx, 'login', {
|
|
641
|
+
uid,
|
|
642
|
+
csrfToken: ctx.request.csrfToken,
|
|
643
|
+
step: 'password',
|
|
644
|
+
email: acc.email,
|
|
645
|
+
account: null,
|
|
646
|
+
brand: brandFor(cfg.branding, clientId ?? undefined, undefined),
|
|
647
|
+
error: translate(cfg.messages, 'errors.email_unverified'),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
651
|
+
accountId: acc.id,
|
|
652
|
+
email: acc.email,
|
|
653
|
+
ip,
|
|
654
|
+
clientId: clientId ?? null,
|
|
655
|
+
metadata: { method: 'magic_link' },
|
|
656
|
+
});
|
|
657
|
+
ctx.session.forget(SESSION_KEY);
|
|
658
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* POST /auth/interaction/:uid/passkey/options
|
|
662
|
+
*
|
|
663
|
+
* Gera as opções de autenticação por passkey. Suporta dois modos:
|
|
664
|
+
*
|
|
665
|
+
* 1. **Modo normal** (MFA gate ou passkey-first com e-mail na sessão):
|
|
666
|
+
* resolve o `accountId` e gera opções com `allowCredentials` restrito às
|
|
667
|
+
* credenciais da conta (comportamento original).
|
|
668
|
+
*
|
|
669
|
+
* 2. **Modo discoverable** (WebAuthn autofill / conditional mediation):
|
|
670
|
+
* chamado sem accountId resolvível (tela de identifier sem e-mail ainda).
|
|
671
|
+
* Neste caso, delega a `generatePasskeyAuthenticationOptions` com accountId
|
|
672
|
+
* especial `'__discoverable__'` — o store deve retornar opções com
|
|
673
|
+
* `allowCredentials: []` (deixa o browser apresentar TODAS as passkeys
|
|
674
|
+
* disponíveis). Quando o store não suporta este modo, devolve 400 com
|
|
675
|
+
* `discoverable: false` (o JS de autofill trata silenciosamente).
|
|
676
|
+
*
|
|
677
|
+
* Guarda o challenge na sessão e devolve as opções JSON.
|
|
678
|
+
*/
|
|
679
|
+
async passkeyOptions(ctx) {
|
|
680
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
681
|
+
const cfg = service.config;
|
|
682
|
+
// Tenta resolver o accountId do contexto corrente (MFA gate ou passkey-first).
|
|
683
|
+
const accountId = await this.resolvePasskeyAccountId(ctx, cfg);
|
|
684
|
+
if (!accountId) {
|
|
685
|
+
// Modo discoverable: sem accountId na sessão, gera options sem restrição
|
|
686
|
+
// de credenciais (allowCredentials vazio = o browser apresenta todas).
|
|
687
|
+
if (!supportsPasskeys(cfg.accountStore)) {
|
|
688
|
+
return ctx.response.badRequest({ message: translate(cfg.messages, 'errors.passkeys_unavailable'), discoverable: false });
|
|
689
|
+
}
|
|
690
|
+
// Passa '__discoverable__' como sentinel — o store (LucidStore) detecta e
|
|
691
|
+
// retorna options com allowCredentials:[] se suportar; caso contrário null.
|
|
692
|
+
const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.('__discoverable__');
|
|
693
|
+
if (!generated) {
|
|
694
|
+
// Store não suporta discoverable credentials — autofill não disponível.
|
|
695
|
+
return ctx.response.badRequest({ message: translate(cfg.messages, 'errors.passkeys_unavailable'), discoverable: false });
|
|
696
|
+
}
|
|
697
|
+
ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
|
|
698
|
+
return { ...generated.options, _discoverable: true };
|
|
699
|
+
}
|
|
700
|
+
// Modo normal: opções restritas à conta.
|
|
701
|
+
const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(accountId);
|
|
702
|
+
if (!generated) {
|
|
703
|
+
return ctx.response.notFound({
|
|
704
|
+
message: translate(cfg.messages, 'errors.no_passkey_registered'),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
|
|
708
|
+
return generated.options;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* POST /auth/interaction/:uid/passkey/verify
|
|
712
|
+
* Verifica a resposta de autenticação por passkey contra o challenge guardado;
|
|
713
|
+
* em caso de sucesso FINALIZA a interaction (303 de volta ao client — alternativa
|
|
714
|
+
* ao código TOTP). É um POST de página inteira (form), não fetch: o browser
|
|
715
|
+
* submete o JSON da assertion no campo `response` e segue o redirect normalmente.
|
|
716
|
+
*/
|
|
717
|
+
async passkeyVerify(ctx) {
|
|
718
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
719
|
+
const cfg = service.config;
|
|
720
|
+
const render = cfg.render;
|
|
721
|
+
const details = await service.interactions.details(ctx);
|
|
722
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
723
|
+
let accountId = await this.resolvePasskeyAccountId(ctx, cfg);
|
|
724
|
+
const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
|
|
725
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
726
|
+
const clientId = details.params.client_id ?? null;
|
|
727
|
+
if (!challenge) {
|
|
728
|
+
// Sessão expirou/foi adulterada — volta ao início do login.
|
|
729
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
730
|
+
}
|
|
731
|
+
// Modo discoverable: quando não temos accountId na sessão, precisamos
|
|
732
|
+
// descobri-lo a partir da credencial na assertion (rawId/id). O store
|
|
733
|
+
// pode implementar `findByPasskeyCredentialId` para isso; se não suportar,
|
|
734
|
+
// não podemos completar o autofill e redirecionamos para o início.
|
|
735
|
+
if (!accountId) {
|
|
736
|
+
const rawResponse = ctx.request.input('response');
|
|
737
|
+
let parsedEarly = null;
|
|
738
|
+
try {
|
|
739
|
+
parsedEarly = rawResponse ? JSON.parse(rawResponse) : null;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
parsedEarly = null;
|
|
743
|
+
}
|
|
744
|
+
const credId = parsedEarly?.id ?? parsedEarly?.rawId ?? null;
|
|
745
|
+
if (credId && typeof cfg.accountStore.findByPasskeyCredentialId === 'function') {
|
|
746
|
+
const found = await cfg.accountStore.findByPasskeyCredentialId(credId);
|
|
747
|
+
accountId = found?.id ?? null;
|
|
748
|
+
}
|
|
749
|
+
if (!accountId) {
|
|
750
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// A assertion vem serializada como JSON no campo `response` do form.
|
|
754
|
+
const raw = ctx.request.input('response');
|
|
755
|
+
let parsed = null;
|
|
756
|
+
try {
|
|
757
|
+
parsed = raw ? JSON.parse(raw) : null;
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
parsed = null;
|
|
761
|
+
}
|
|
762
|
+
const ok = parsed
|
|
763
|
+
? ((await cfg.accountStore.verifyPasskeyAuthentication?.(accountId, parsed, challenge)) ?? false)
|
|
764
|
+
: false;
|
|
765
|
+
ctx.session.forget(PASSKEY_AUTH_CHALLENGE_KEY);
|
|
766
|
+
if (!ok) {
|
|
767
|
+
await cfg.audit?.record({
|
|
768
|
+
type: 'login.failure',
|
|
769
|
+
accountId,
|
|
770
|
+
ip,
|
|
771
|
+
clientId,
|
|
772
|
+
metadata: { stage: 'mfa', method: 'webauthn' },
|
|
773
|
+
});
|
|
774
|
+
return render(ctx, 'mfa-challenge', {
|
|
775
|
+
uid: ctx.request.param('uid'),
|
|
776
|
+
csrfToken: ctx.request.csrfToken,
|
|
777
|
+
error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
|
|
778
|
+
brand,
|
|
779
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
780
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
781
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
// E-mail não verificado (LGPD/compliance): bloqueia mesmo com a passkey válida.
|
|
785
|
+
// Relevante sobretudo no passkey-first (a etapa de senha — que também checa —
|
|
786
|
+
// não rodou). Volta à tela de desafio com o erro.
|
|
787
|
+
const passkeyRuntimeSettings = await getRuntimeSettings(ctx);
|
|
788
|
+
if (await isEmailUnverifiedBlock(cfg, accountId, passkeyRuntimeSettings)) {
|
|
789
|
+
await cfg.audit?.record({
|
|
790
|
+
type: 'login.failure',
|
|
791
|
+
accountId,
|
|
792
|
+
ip,
|
|
793
|
+
clientId,
|
|
794
|
+
metadata: { stage: 'mfa', method: 'webauthn', reason: 'unverified' },
|
|
795
|
+
});
|
|
796
|
+
return render(ctx, 'mfa-challenge', {
|
|
797
|
+
uid: ctx.request.param('uid'),
|
|
798
|
+
csrfToken: ctx.request.csrfToken,
|
|
799
|
+
error: translate(cfg.messages, 'errors.email_unverified'),
|
|
800
|
+
brand,
|
|
801
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
802
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
803
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
|
|
807
|
+
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
808
|
+
ctx.session.forget(MFA_PENDING_KEY);
|
|
809
|
+
ctx.session.forget(SESSION_KEY);
|
|
810
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
811
|
+
accountId,
|
|
812
|
+
ip,
|
|
813
|
+
clientId,
|
|
814
|
+
metadata: { mfa: 'webauthn' },
|
|
815
|
+
});
|
|
816
|
+
// Step-up carimba acr/amr quando solicitado; senão a passkey conta como o fator
|
|
817
|
+
// forte do login (amr `['webauthn']`) — vale tanto p/ MFA quanto passkey-first.
|
|
818
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] });
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* GET /auth/interaction/:uid/switch
|
|
822
|
+
* Clears the stored email and redirects back to step 1.
|
|
823
|
+
*/
|
|
824
|
+
async switchIdentifier(ctx) {
|
|
825
|
+
ctx.session.forget(SESSION_KEY);
|
|
826
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* POST /auth/interaction/:uid/password-expired
|
|
830
|
+
*
|
|
831
|
+
* Step de troca obrigatória quando a senha expirou. A senha VELHA já foi
|
|
832
|
+
* verificada em `login` — aqui a conta está no step `password_expired` da
|
|
833
|
+
* sessão. O usuário informa a nova senha (+ confirmação) e o controller:
|
|
834
|
+
* 1. Valida política de senha.
|
|
835
|
+
* 2. Atualiza a senha via `changePassword` (que toca `passwordChangedAt`).
|
|
836
|
+
* 3. Finaliza a interaction normalmente.
|
|
837
|
+
*
|
|
838
|
+
* Capability-probed: sem `changePassword` → redireciona para o login.
|
|
839
|
+
*/
|
|
840
|
+
async changeExpiredPassword(ctx) {
|
|
841
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
842
|
+
const cfg = service.config;
|
|
843
|
+
const render = cfg.render;
|
|
844
|
+
const details = await service.interactions.details(ctx);
|
|
845
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
846
|
+
const PASSWORD_EXPIRED_KEY = 'authkit_password_expired';
|
|
847
|
+
const accountId = ctx.session.get(PASSWORD_EXPIRED_KEY);
|
|
848
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
849
|
+
const clientId = details.params.client_id ?? null;
|
|
850
|
+
if (!accountId) {
|
|
851
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
852
|
+
}
|
|
853
|
+
const { newPassword } = ctx.request.only(['newPassword']);
|
|
854
|
+
if (!newPassword) {
|
|
855
|
+
return render(ctx, 'login', {
|
|
856
|
+
uid: ctx.request.param('uid'),
|
|
857
|
+
csrfToken: ctx.request.csrfToken,
|
|
858
|
+
step: 'password_expired',
|
|
859
|
+
email: '',
|
|
860
|
+
account: null,
|
|
861
|
+
error: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
862
|
+
brand,
|
|
863
|
+
botProtection: undefined,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
const { PasswordPolicyError } = await import('../../password/password_manager.js');
|
|
867
|
+
const { supportsAccountSecurity } = await import('../../accounts/account_store.js');
|
|
868
|
+
if (!supportsAccountSecurity(cfg.accountStore)) {
|
|
869
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
await cfg.accountStore.changePassword(accountId, newPassword);
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
if (error instanceof PasswordPolicyError) {
|
|
876
|
+
return render(ctx, 'login', {
|
|
877
|
+
uid: ctx.request.param('uid'),
|
|
878
|
+
csrfToken: ctx.request.csrfToken,
|
|
879
|
+
step: 'password_expired',
|
|
880
|
+
email: '',
|
|
881
|
+
account: null,
|
|
882
|
+
error: translate(cfg.messages, error.key, error.params),
|
|
883
|
+
brand,
|
|
884
|
+
botProtection: undefined,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
await cfg.audit?.record({
|
|
890
|
+
type: 'password.changed',
|
|
891
|
+
accountId,
|
|
892
|
+
ip,
|
|
893
|
+
clientId,
|
|
894
|
+
metadata: { reason: 'expired_forced' },
|
|
895
|
+
});
|
|
896
|
+
// Senha trocada: limpa o step e finaliza o login.
|
|
897
|
+
ctx.session.forget(PASSWORD_EXPIRED_KEY);
|
|
898
|
+
// Verifica se precisa de MFA mesmo após a troca.
|
|
899
|
+
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
900
|
+
if (mfa.enabled) {
|
|
901
|
+
ctx.session.put(MFA_PENDING_KEY, accountId);
|
|
902
|
+
const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
|
|
903
|
+
return render(ctx, 'mfa-challenge', {
|
|
904
|
+
uid: ctx.request.param('uid'),
|
|
905
|
+
csrfToken: ctx.request.csrfToken,
|
|
906
|
+
brand,
|
|
907
|
+
passkeyAvailable,
|
|
908
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
909
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
await service.interactions.completeLogin(ctx, accountId);
|
|
913
|
+
const account = await cfg.accountStore.findById(accountId);
|
|
914
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
915
|
+
accountId,
|
|
916
|
+
email: account?.email ?? '',
|
|
917
|
+
ip,
|
|
918
|
+
clientId,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
async consent(ctx) {
|
|
922
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
923
|
+
await service.interactions.consent(ctx);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* GET /auth/otp-unlock/:token
|
|
927
|
+
* Consome o token de desbloqueio OTP enviado por e-mail. Valida (hash + TTL),
|
|
928
|
+
* zera o contador/lock, audita `otp.unlocked` e redireciona ao login com mensagem.
|
|
929
|
+
*/
|
|
930
|
+
async otpUnlock(ctx) {
|
|
931
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
932
|
+
const cfg = service.config;
|
|
933
|
+
const render = cfg.render;
|
|
934
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
935
|
+
const raw = ctx.request.param('token');
|
|
936
|
+
if (!raw) {
|
|
937
|
+
return render(ctx, 'otp-unlock', { ok: false });
|
|
938
|
+
}
|
|
939
|
+
const dbValue = rawToDbOtpUnlockToken(raw);
|
|
940
|
+
try {
|
|
941
|
+
// Busca a conta pelo token (campo passwordResetToken).
|
|
942
|
+
const store = cfg.accountStore;
|
|
943
|
+
// Usamos findByPasswordResetToken se disponível; senão fallback via raw row scan.
|
|
944
|
+
let row = null;
|
|
945
|
+
if (typeof store.__findByTokenField === 'function') {
|
|
946
|
+
row = await store.__findByTokenField('passwordResetToken', dbValue);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
// Fallback: busca direto via raw query (Lucid model).
|
|
950
|
+
const Model = store.__Model;
|
|
951
|
+
if (Model) {
|
|
952
|
+
row = await Model.query().where('passwordResetToken', dbValue).first();
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (!row) {
|
|
956
|
+
await cfg.audit?.record({ type: 'otp.unlock_failed', ip, metadata: { reason: 'token_not_found' } });
|
|
957
|
+
return render(ctx, 'otp-unlock', { ok: false });
|
|
958
|
+
}
|
|
959
|
+
// Verifica TTL.
|
|
960
|
+
const { DateTime } = await import('luxon');
|
|
961
|
+
const expiresAt = row.passwordResetExpiresAt;
|
|
962
|
+
if (!expiresAt || expiresAt < DateTime.now()) {
|
|
963
|
+
await cfg.audit?.record({ type: 'otp.unlock_failed', accountId: row.id, ip, metadata: { reason: 'token_expired' } });
|
|
964
|
+
return render(ctx, 'otp-unlock', { ok: false, expired: true });
|
|
965
|
+
}
|
|
966
|
+
// Token válido: zera o lock OTP + limpa o token do DB.
|
|
967
|
+
const runtimeForOtp = await getRuntimeSettings(ctx);
|
|
968
|
+
const otpLockoutCfg = await resolveEffectiveOtpLockout(runtimeForOtp);
|
|
969
|
+
const otpLockout = createOtpLockout(otpLockoutCfg, ctx.logger);
|
|
970
|
+
await otpLockout.unlock(row.id);
|
|
971
|
+
row.passwordResetToken = null;
|
|
972
|
+
row.passwordResetExpiresAt = null;
|
|
973
|
+
await row.save();
|
|
974
|
+
await cfg.audit?.record({ type: 'otp.unlocked', accountId: row.id, ip });
|
|
975
|
+
return render(ctx, 'otp-unlock', { ok: true });
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
return render(ctx, 'otp-unlock', { ok: false });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|