simple-module-users 0.0.38__tar.gz → 0.0.39__tar.gz
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.
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/PKG-INFO +9 -9
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/package.json +1 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/pyproject.toml +9 -9
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/_users_app_builders.py +5 -0
- simple_module_users-0.0.39/tests/demo/test_users_demo_credentials.py +139 -0
- simple_module_users-0.0.38/tests/test_demo_guard.py → simple_module_users-0.0.39/tests/demo/test_users_demo_guard.py +1 -1
- simple_module_users-0.0.38/tests/test_admin_resend_invite.py → simple_module_users-0.0.39/tests/invites/test_users_admin_resend_invite.py +2 -2
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/invites}/test_users_bulk_invite.py +2 -2
- simple_module_users-0.0.39/tests/lockout/test_users_lockout_checkpoint.py +114 -0
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/lockout}/test_users_lockout_service.py +9 -9
- simple_module_users-0.0.39/tests/test_users_default_role.py +75 -0
- simple_module_users-0.0.39/tests/test_users_domain_errors.py +40 -0
- simple_module_users-0.0.38/tests/test_user_manager.py → simple_module_users-0.0.39/tests/test_users_manager.py +0 -33
- simple_module_users-0.0.39/tests/test_users_register_single_connection.py +55 -0
- simple_module_users-0.0.39/tests/test_users_startup_role.py +83 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/api.py +2 -2
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/links.py +2 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/queries.py +3 -2
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/service.py +5 -4
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/api.py +3 -0
- simple_module_users-0.0.38/users/auth_local/lockout.py → simple_module_users-0.0.39/users/auth_local/lockout/__init__.py +36 -116
- simple_module_users-0.0.39/users/auth_local/lockout/attempt.py +87 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/self_account.py +5 -4
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/token_api.py +26 -11
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/token_preview.py +1 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/views.py +1 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/bootstrap.py +31 -27
- simple_module_users-0.0.38/users/demo.py → simple_module_users-0.0.39/users/demo/__init__.py +7 -4
- simple_module_users-0.0.38/users/auth_local/demo_api.py → simple_module_users-0.0.39/users/demo/api.py +1 -1
- simple_module_users-0.0.38/users/demo_guard.py → simple_module_users-0.0.39/users/demo/guard.py +39 -13
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/directory.py +14 -24
- simple_module_users-0.0.39/users/errors.py +19 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/exceptions.py +19 -4
- simple_module_users-0.0.39/users/invites/__init__.py +1 -0
- simple_module_users-0.0.38/users/admin/bulk_invite.py → simple_module_users-0.0.39/users/invites/bulk.py +19 -39
- simple_module_users-0.0.38/users/admin/resend_invite.py → simple_module_users-0.0.39/users/invites/resend.py +5 -5
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/module.py +9 -8
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/useEditUserForm.ts +2 -16
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/provider.py +2 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/registration.py +17 -11
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/session_revocation.py +3 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/shared_props.py +1 -1
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/startup.py +18 -6
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/token_strategy.py +5 -4
- simple_module_users-0.0.38/tests/test_users_default_role.py +0 -82
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/.gitignore +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/LICENSE +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/README.md +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/_users_oauth_support.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/conftest.py +0 -0
- /simple_module_users-0.0.38/tests/test_demo_account.py → /simple_module_users-0.0.39/tests/demo/test_users_demo_account.py +0 -0
- /simple_module_users-0.0.38/tests/test_demo_login.py → /simple_module_users-0.0.39/tests/demo/test_users_demo_login.py +0 -0
- /simple_module_users-0.0.38/tests/test_bulk_invite_message.py → /simple_module_users-0.0.39/tests/invites/test_users_bulk_invite_message.py +0 -0
- /simple_module_users-0.0.38/tests/test_invite_flow.py → /simple_module_users-0.0.39/tests/invites/test_users_invite_flow.py +0 -0
- /simple_module_users-0.0.38/tests/test_invite_reuse.py → /simple_module_users-0.0.39/tests/invites/test_users_invite_reuse.py +0 -0
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/invites}/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/lockout}/test_users_admin_unlock.py +0 -0
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/lockout}/test_users_lockout_columns.py +0 -0
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/lockout}/test_users_lockout_enumeration.py +0 -0
- {simple_module_users-0.0.38/tests → simple_module_users-0.0.39/tests/lockout}/test_users_login_lockout.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_api_admin_pagination_bounds.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_bearer_token_expiry.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_legacy_cached_tenant.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_login_redirect_root_mount.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_remember_me.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_session_version_atomic.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_admin_session_revocation.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_bearer_scheme.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_directory.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_mailer_core_sender.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_oauth_signup_gate.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_oauth_unverified_link.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_password_change_keeps_api.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_register_role_durable.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_require_verification.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_reset_link_origin.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_seed_role_migration.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_setup_lock.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_setup_wizard.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_views.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/AccountCardLocked.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/LoginForm.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/registerErrors.test.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/users_Login.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/users_Register.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/users_actions.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/users_fixtures.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tests-js/users_profile_cards.test.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/tsconfig.json +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/useUserRowActions.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/admin/views.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/audit.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/LoginForm.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/auth_local/registerErrors.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/backend.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/cli.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/constants.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/contracts/roles.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/deps.py +0 -0
- /simple_module_users-0.0.38/users/auth_local/invite_preview.py → /simple_module_users-0.0.39/users/invites/preview.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/locales/de.json +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/locales/en.json +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/mailer/templated.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/manager.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/middleware.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/_base.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/role.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/user.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/MailerBanner.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/useAddPeople.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/components/useUserActions.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/api.generated.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/api.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/failureMessage.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/pages/signIn.ts +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/py.typed +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/settings.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/setup.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/setup_action.py +0 -0
- {simple_module_users-0.0.38 → simple_module_users-0.0.39}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.39
|
|
4
4
|
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,15 +21,15 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: cachetools
|
|
24
|
+
Requires-Dist: cachetools<8,>=5.3
|
|
25
25
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
26
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
27
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
30
|
-
Requires-Dist: simple-module-mail==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
32
|
-
Requires-Dist: typer
|
|
26
|
+
Requires-Dist: simple-module-auth==0.0.39
|
|
27
|
+
Requires-Dist: simple-module-core==0.0.39
|
|
28
|
+
Requires-Dist: simple-module-db==0.0.39
|
|
29
|
+
Requires-Dist: simple-module-hosting==0.0.39
|
|
30
|
+
Requires-Dist: simple-module-mail==0.0.39
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.39
|
|
32
|
+
Requires-Dist: typer<0.28,>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
35
|
# simple_module_users
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.39"
|
|
4
4
|
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,20 +21,20 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
24
|
+
"simple_module_core==0.0.39",
|
|
25
|
+
"simple_module_db==0.0.39",
|
|
26
|
+
"simple_module_hosting==0.0.39",
|
|
27
|
+
"simple_module_settings==0.0.39",
|
|
28
|
+
"simple_module_auth==0.0.39",
|
|
29
29
|
# Sends through the framework mail sender; `mail` owns the SMTP transport
|
|
30
30
|
# (GH #496), so an upgrade keeps delivering mail.
|
|
31
|
-
"simple_module_mail==0.0.
|
|
31
|
+
"simple_module_mail==0.0.39",
|
|
32
32
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
33
33
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
34
34
|
# Bumping the major version requires re-checking those field names.
|
|
35
35
|
"fastapi-users[sqlalchemy,oauth]>=15,<16",
|
|
36
|
-
"cachetools>=5.3",
|
|
37
|
-
"typer>=0.12",
|
|
36
|
+
"cachetools>=5.3,<8",
|
|
37
|
+
"typer>=0.12,<0.28",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
40
|
[project.entry-points.simple_module]
|
|
@@ -84,6 +84,11 @@ async def _seed_roles(application) -> None:
|
|
|
84
84
|
if "user" not in existing:
|
|
85
85
|
session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user"))
|
|
86
86
|
await session.commit()
|
|
87
|
+
# Direct inserts bypass the role service that would refresh the cache; startup
|
|
88
|
+
# already warmed it (with the role it seeds), so refresh like a real write would.
|
|
89
|
+
from users.roles_cache import refresh_roles_cache
|
|
90
|
+
|
|
91
|
+
await refresh_roles_cache(application)
|
|
87
92
|
|
|
88
93
|
|
|
89
94
|
async def _seed_users_settings(
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Demo read-only mode follows identities authenticated without demo cookies."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from api_tokens.constants import TokenKind
|
|
6
|
+
from api_tokens.service import ApiTokenService
|
|
7
|
+
from simple_module_test import forge_session_cookie
|
|
8
|
+
from users.auth_local.token_api import _create_token_pair
|
|
9
|
+
from users.demo import ensure_demo_users
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
async def demo_credentials(demo_app):
|
|
14
|
+
settings = demo_app.state.users.settings
|
|
15
|
+
settings.demo_admin_password = "DemoPass1!"
|
|
16
|
+
await ensure_demo_users(demo_app)
|
|
17
|
+
user_id = demo_app.state.users.demo_user_ids[0]
|
|
18
|
+
async with demo_app.state.sm.db.session_factory() as db:
|
|
19
|
+
pair = await _create_token_pair(db, user_id, settings)
|
|
20
|
+
_, pat = await ApiTokenService(db).create(
|
|
21
|
+
kind=TokenKind.PERSONAL,
|
|
22
|
+
name="pre-existing demo PAT",
|
|
23
|
+
scopes=["users.manage", "api_tokens.use"],
|
|
24
|
+
expires_in_days=1,
|
|
25
|
+
max_lifetime_days=30,
|
|
26
|
+
owner_user_id=user_id,
|
|
27
|
+
)
|
|
28
|
+
await db.commit()
|
|
29
|
+
return {"bearer": pair.access_token, "pat": pat, "refresh": pair.refresh_token}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("credential", ["bearer", "pat"])
|
|
33
|
+
@pytest.mark.parametrize("read_only", [True, False])
|
|
34
|
+
async def test_demo_token_writes_follow_read_only_setting(
|
|
35
|
+
demo_app, demo_client, demo_credentials, credential, read_only
|
|
36
|
+
):
|
|
37
|
+
demo_app.state.users.settings.demo_read_only = read_only
|
|
38
|
+
headers = {"Authorization": f"Bearer {demo_credentials[credential]}"}
|
|
39
|
+
# A successful read proves this credential actually resolves the demo identity.
|
|
40
|
+
target = demo_app.state.users.demo_user_ids[1]
|
|
41
|
+
assert (await demo_client.get("/api/users/admin", headers=headers)).status_code == 200
|
|
42
|
+
response = await demo_client.patch(
|
|
43
|
+
f"/api/users/admin/{target}",
|
|
44
|
+
json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
|
|
45
|
+
headers=headers,
|
|
46
|
+
)
|
|
47
|
+
assert response.status_code == (403 if read_only else 200), response.text
|
|
48
|
+
if read_only:
|
|
49
|
+
assert response.json() == {"detail": "DEMO_READ_ONLY"}
|
|
50
|
+
else:
|
|
51
|
+
assert response.json()["full_name"] == "Changed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("credential", ["bearer", "pat"])
|
|
55
|
+
async def test_demo_token_inertia_write_uses_existing_refusal(
|
|
56
|
+
demo_app, demo_client, demo_credentials, credential
|
|
57
|
+
):
|
|
58
|
+
response = await demo_client.patch(
|
|
59
|
+
f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
|
|
60
|
+
json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
|
|
61
|
+
headers={
|
|
62
|
+
"Authorization": f"Bearer {demo_credentials[credential]}",
|
|
63
|
+
"X-Inertia": "true",
|
|
64
|
+
"Referer": "http://testserver/admin/users/",
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
assert response.status_code == 409
|
|
68
|
+
assert response.headers["X-Inertia-Location"] == "http://testserver/admin/users/"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("operation", ["login", "refresh"])
|
|
72
|
+
async def test_demo_cannot_mint_token_pair_without_session(
|
|
73
|
+
demo_app, demo_client, demo_credentials, operation
|
|
74
|
+
):
|
|
75
|
+
if operation == "login":
|
|
76
|
+
path = "/api/users/auth/token"
|
|
77
|
+
body = {"email": demo_app.state.users.settings.demo_admin_email, "password": "DemoPass1!"}
|
|
78
|
+
else:
|
|
79
|
+
path = "/api/users/auth/token/refresh"
|
|
80
|
+
body = {"refresh_token": demo_credentials["refresh"]}
|
|
81
|
+
response = await demo_client.post(path, json=body)
|
|
82
|
+
assert response.status_code == 403, response.text
|
|
83
|
+
assert response.json() == {"detail": "DEMO_READ_ONLY"}
|
|
84
|
+
# Refusal must not consume the refresh token or leave a login lockout slot.
|
|
85
|
+
demo_app.state.users.settings.demo_read_only = False
|
|
86
|
+
assert (await demo_client.post(path, json=body)).status_code == 200
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_demo_session_cannot_create_pat(demo_app):
|
|
90
|
+
cookie = forge_session_cookie(
|
|
91
|
+
str(demo_app.state.sm.settings.secret_key),
|
|
92
|
+
{"user_id": str(demo_app.state.users.demo_user_ids[0]), "sm_csrf_token": "csrf"},
|
|
93
|
+
)
|
|
94
|
+
async with httpx.AsyncClient(
|
|
95
|
+
transport=httpx.ASGITransport(app=demo_app),
|
|
96
|
+
base_url="http://testserver",
|
|
97
|
+
cookies={"session": cookie},
|
|
98
|
+
) as client:
|
|
99
|
+
response = await client.post(
|
|
100
|
+
"/api/api_tokens/tokens/",
|
|
101
|
+
json={"name": "demo", "scopes": ["users.manage"], "expires_in_days": 1},
|
|
102
|
+
headers={"X-CSRF-Token": "csrf"},
|
|
103
|
+
)
|
|
104
|
+
assert response.status_code == 403
|
|
105
|
+
assert response.json() == {"detail": "DEMO_READ_ONLY"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_real_bearer_account_can_still_write_on_demo_instance(demo_app, demo_client):
|
|
109
|
+
response = await demo_client.post(
|
|
110
|
+
"/api/users/auth/token", json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
111
|
+
)
|
|
112
|
+
assert response.status_code == 200
|
|
113
|
+
response = await demo_client.patch(
|
|
114
|
+
f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
|
|
115
|
+
json={
|
|
116
|
+
"email": demo_app.state.users.settings.demo_user_email,
|
|
117
|
+
"full_name": "Real admin write",
|
|
118
|
+
},
|
|
119
|
+
headers={"Authorization": f"Bearer {response.json()['access_token']}"},
|
|
120
|
+
)
|
|
121
|
+
assert response.status_code == 200
|
|
122
|
+
assert response.json()["full_name"] == "Real admin write"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_demo_token_login_still_checks_password(demo_app, demo_client, demo_credentials):
|
|
126
|
+
response = await demo_client.post(
|
|
127
|
+
"/api/users/auth/token",
|
|
128
|
+
json={"email": demo_app.state.users.settings.demo_admin_email, "password": "wrong"},
|
|
129
|
+
)
|
|
130
|
+
assert response.status_code == 401
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("credential", ["bearer", "pat"])
|
|
134
|
+
async def test_demo_token_can_still_logout(demo_client, demo_credentials, credential):
|
|
135
|
+
response = await demo_client.post(
|
|
136
|
+
"/api/users/auth/logout",
|
|
137
|
+
headers={"Authorization": f"Bearer {demo_credentials[credential]}"},
|
|
138
|
+
)
|
|
139
|
+
assert response.status_code == 204
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import httpx
|
|
10
10
|
from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME
|
|
11
11
|
from users.demo import SESSION_DEMO_KEY
|
|
12
|
-
from users.
|
|
12
|
+
from users.demo.guard import DEMO_READ_ONLY_DETAIL
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _demo_route(role: str) -> str:
|
|
@@ -202,7 +202,7 @@ class TestMailerFailureIsNotEchoed:
|
|
|
202
202
|
async def test_the_admin_gets_the_link_and_a_generic_reason(
|
|
203
203
|
self, admin_client, users_app, caplog
|
|
204
204
|
):
|
|
205
|
-
from users.
|
|
205
|
+
from users.invites.bulk import MAILER_FAILURE_DETAIL
|
|
206
206
|
|
|
207
207
|
class _BrokenMailer(_SpyMailer):
|
|
208
208
|
async def send_invite(self, email, token, invited_by_name, message=None):
|
|
@@ -212,7 +212,7 @@ class TestMailerFailureIsNotEchoed:
|
|
|
212
212
|
original = users_app.state.users.mailer
|
|
213
213
|
users_app.state.users.mailer = _BrokenMailer()
|
|
214
214
|
try:
|
|
215
|
-
with caplog.at_level(logging.ERROR, logger="users.
|
|
215
|
+
with caplog.at_level(logging.ERROR, logger="users.invites.resend"):
|
|
216
216
|
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
217
217
|
finally:
|
|
218
218
|
users_app.state.users.mailer = original
|
|
@@ -139,7 +139,7 @@ class TestBulkInvite:
|
|
|
139
139
|
|
|
140
140
|
async def test_address_count_is_capped(self, authenticated_client: httpx.AsyncClient):
|
|
141
141
|
"""One submit must not be able to mint unbounded live invite tokens."""
|
|
142
|
-
from users.
|
|
142
|
+
from users.invites.bulk import MAX_ADDRESSES
|
|
143
143
|
|
|
144
144
|
emails = [f"bulk{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
|
|
145
145
|
resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
|
|
@@ -151,7 +151,7 @@ class TestBulkInvite:
|
|
|
151
151
|
):
|
|
152
152
|
"""Silent truncation reports "100 invites sent" while 5 people are
|
|
153
153
|
never contacted, with nothing on screen saying so."""
|
|
154
|
-
from users.
|
|
154
|
+
from users.invites.bulk import MAX_ADDRESSES
|
|
155
155
|
|
|
156
156
|
emails = [f"over{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
|
|
157
157
|
resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Checkpoint isolation on file SQLite, where overlapping writers cannot hide."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from _users_app_builders import ALICE, _build_users_app, _client, _make_standard_user, user_row
|
|
7
|
+
from pwdlib.hashers.bcrypt import BcryptHasher
|
|
8
|
+
from simple_module_core.rate_limit import InProcessWindowStore
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from users.auth_local.lockout import LoginAttempt, admit
|
|
11
|
+
from users.models import Role, User
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
async def checkpoint_app(monkeypatch, tmp_path):
|
|
16
|
+
app, context = await _build_users_app(
|
|
17
|
+
monkeypatch,
|
|
18
|
+
allow_signup=False,
|
|
19
|
+
database_url=f"sqlite+aiosqlite:///{tmp_path / 'auth-checkpoint.db'}",
|
|
20
|
+
)
|
|
21
|
+
await _make_standard_user(app, ALICE)
|
|
22
|
+
try:
|
|
23
|
+
yield app
|
|
24
|
+
finally:
|
|
25
|
+
await context.__aexit__(None, None, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_admission_never_flushes_or_commits_unrelated_request_work(checkpoint_app):
|
|
29
|
+
observed = []
|
|
30
|
+
factory = checkpoint_app.state.sm.db.session_factory
|
|
31
|
+
attempt = LoginAttempt.build(
|
|
32
|
+
ALICE,
|
|
33
|
+
"1.2.3.4",
|
|
34
|
+
InProcessWindowStore(),
|
|
35
|
+
checkpoint_app.state.users.settings,
|
|
36
|
+
datetime.now(UTC),
|
|
37
|
+
)
|
|
38
|
+
async with factory() as request:
|
|
39
|
+
pending = Role(name="pending-request-role")
|
|
40
|
+
request.add(pending)
|
|
41
|
+
request.on_commit(lambda: observed.append("request"))
|
|
42
|
+
admission = await admit(request, attempt)
|
|
43
|
+
assert admission.count == 1
|
|
44
|
+
assert pending in request.new
|
|
45
|
+
assert observed == []
|
|
46
|
+
await request.rollback()
|
|
47
|
+
assert (await user_row(checkpoint_app, ALICE)).failed_login_count == 1
|
|
48
|
+
async with factory() as reader:
|
|
49
|
+
assert (
|
|
50
|
+
await reader.execute(select(Role).where(Role.name == "pending-request-role"))
|
|
51
|
+
).scalar_one_or_none() is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def test_unverified_legacy_password_releases_slot_without_overlapping_writers(checkpoint_app):
|
|
55
|
+
factory = checkpoint_app.state.sm.db.session_factory
|
|
56
|
+
async with factory() as session:
|
|
57
|
+
user = (await session.execute(select(User).where(User.email == ALICE))).scalar_one()
|
|
58
|
+
user.is_verified = False
|
|
59
|
+
user.hashed_password = BcryptHasher(rounds=4).hash("UserPass1!")
|
|
60
|
+
await session.commit()
|
|
61
|
+
checkpoint_app.state.users.settings = checkpoint_app.state.users.settings.model_copy(
|
|
62
|
+
update={"require_verification": True}
|
|
63
|
+
)
|
|
64
|
+
async with _client(checkpoint_app, "1.2.3.4") as client:
|
|
65
|
+
response = await client.post(
|
|
66
|
+
"/api/users/auth/token", json={"email": ALICE, "password": "UserPass1!"}
|
|
67
|
+
)
|
|
68
|
+
assert response.status_code == 400, response.text
|
|
69
|
+
assert response.json()["detail"] == "LOGIN_USER_NOT_VERIFIED"
|
|
70
|
+
assert (await user_row(checkpoint_app, ALICE)).failed_login_count == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("kind", ["cookie", "token"])
|
|
74
|
+
async def test_unverified_logins_release_request_connections_before_checkpoint(
|
|
75
|
+
checkpoint_app, monkeypatch, kind
|
|
76
|
+
):
|
|
77
|
+
import asyncio
|
|
78
|
+
|
|
79
|
+
from users.auth_local import api, token_api
|
|
80
|
+
|
|
81
|
+
# SQLite's default pool has five connections plus ten overflow connections.
|
|
82
|
+
emails = [f"unverified-{i}@example.com" for i in range(15)]
|
|
83
|
+
for email in emails:
|
|
84
|
+
await _make_standard_user(checkpoint_app, email)
|
|
85
|
+
async with checkpoint_app.state.sm.db.session_factory() as session:
|
|
86
|
+
for user in (await session.execute(select(User).where(User.email.in_(emails)))).scalars():
|
|
87
|
+
user.is_verified = False
|
|
88
|
+
await session.commit()
|
|
89
|
+
checkpoint_app.state.users.settings = checkpoint_app.state.users.settings.model_copy(
|
|
90
|
+
update={"require_verification": True}
|
|
91
|
+
)
|
|
92
|
+
endpoint = api if kind == "cookie" else token_api
|
|
93
|
+
real_release = endpoint.release
|
|
94
|
+
ready = asyncio.Barrier(len(emails))
|
|
95
|
+
|
|
96
|
+
async def together(session, attempt, admission):
|
|
97
|
+
await ready.wait()
|
|
98
|
+
return await real_release(session, attempt, admission)
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(endpoint, "release", together)
|
|
101
|
+
|
|
102
|
+
async def login(email):
|
|
103
|
+
async with _client(checkpoint_app, "1.2.3.4") as client:
|
|
104
|
+
if kind == "cookie":
|
|
105
|
+
return await client.post(
|
|
106
|
+
"/api/users/auth/login", data={"username": email, "password": "UserPass1!"}
|
|
107
|
+
)
|
|
108
|
+
return await client.post(
|
|
109
|
+
"/api/users/auth/token", json={"email": email, "password": "UserPass1!"}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
responses = await asyncio.wait_for(asyncio.gather(*(login(email) for email in emails)), 5)
|
|
113
|
+
assert [response.status_code for response in responses] == [400] * 15
|
|
114
|
+
assert all(response.json()["detail"] == "LOGIN_USER_NOT_VERIFIED" for response in responses)
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import UTC, datetime, timedelta
|
|
6
6
|
|
|
7
7
|
from _users_app_builders import ALICE
|
|
8
|
-
from fastapi import HTTPException
|
|
9
8
|
from simple_module_core.rate_limit import InProcessWindowStore
|
|
10
9
|
from sqlalchemy import select
|
|
11
10
|
from users.auth_local import lockout
|
|
@@ -16,6 +15,7 @@ from users.auth_local.lockout import (
|
|
|
16
15
|
record_failure,
|
|
17
16
|
record_success,
|
|
18
17
|
)
|
|
18
|
+
from users.exceptions import LoginLockedError
|
|
19
19
|
from users.models import User
|
|
20
20
|
from users.settings import UsersSettings
|
|
21
21
|
|
|
@@ -38,17 +38,17 @@ async def _fail(session, attempt: LoginAttempt) -> int:
|
|
|
38
38
|
"""One wrong password: admit, then settle. Returns the status it would answer."""
|
|
39
39
|
try:
|
|
40
40
|
admission = await admit(session, attempt)
|
|
41
|
-
except
|
|
42
|
-
return
|
|
41
|
+
except LoginLockedError:
|
|
42
|
+
return 429
|
|
43
43
|
locked = await record_failure(session, attempt, admission)
|
|
44
|
-
return
|
|
44
|
+
return 429 if locked else 400
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
async def _admit_or_status(session, attempt: LoginAttempt) -> Admission | int:
|
|
48
48
|
try:
|
|
49
49
|
return await admit(session, attempt)
|
|
50
|
-
except
|
|
51
|
-
return
|
|
50
|
+
except LoginLockedError:
|
|
51
|
+
return 429
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
async def _row(users_db, user) -> User:
|
|
@@ -160,7 +160,7 @@ async def test_success_mid_burst_does_not_lift_a_lock(users_db, alice) -> None:
|
|
|
160
160
|
owner_slot = await admit(users_db, owner) # admitted before the lock
|
|
161
161
|
attacker = _attempt(ip="10.0.0.3", store=store, account=3)
|
|
162
162
|
locked = await record_failure(users_db, attacker, await admit(users_db, attacker))
|
|
163
|
-
assert locked
|
|
163
|
+
assert isinstance(locked, LoginLockedError)
|
|
164
164
|
|
|
165
165
|
await record_success(users_db, owner, owner_slot) # the owner's password lands last
|
|
166
166
|
await users_db.commit()
|
|
@@ -188,8 +188,8 @@ async def test_lock_landing_after_the_read_reports_the_time_left(users_db, alice
|
|
|
188
188
|
mid = _attempt(now=T0 + timedelta(seconds=300), ip="9.9.9.9", account=3)
|
|
189
189
|
try:
|
|
190
190
|
await lockout._admit_account(users_db, mid, [None]) # read before the lock landed
|
|
191
|
-
except
|
|
192
|
-
assert
|
|
191
|
+
except LoginLockedError as exc:
|
|
192
|
+
assert exc.retry_after == 600
|
|
193
193
|
else:
|
|
194
194
|
raise AssertionError("a locked row was admitted")
|
|
195
195
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Which registrations receive the standard role, using real checkpoint sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from users.constants import OAUTH_REGISTRATION_REQUEST_FLAG, USER_ROLE_ID
|
|
10
|
+
from users.db_adapter import UserDatabaseWithRoles
|
|
11
|
+
from users.manager import UserManager
|
|
12
|
+
from users.models import OAuthAccount, Role, User, UserRole
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _manager(app, session, *, allow_signup: bool) -> UserManager:
|
|
16
|
+
settings = app.state.users.settings.model_copy(update={"allow_signup": allow_signup})
|
|
17
|
+
return UserManager(
|
|
18
|
+
UserDatabaseWithRoles(session, User, OAuthAccount),
|
|
19
|
+
MagicMock(send_verification=AsyncMock()),
|
|
20
|
+
settings,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _request(*, oauth: bool) -> MagicMock:
|
|
25
|
+
request = MagicMock()
|
|
26
|
+
request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
|
|
27
|
+
request.state = SimpleNamespace(**({OAUTH_REGISTRATION_REQUEST_FLAG: True} if oauth else {}))
|
|
28
|
+
return request
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _user(session) -> User:
|
|
32
|
+
user = User(email="new@example.com", is_verified=True)
|
|
33
|
+
session.add(user)
|
|
34
|
+
await session.commit() # fastapi-users creates the account before invoking its hook
|
|
35
|
+
return user
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _roles(session, user: User) -> list:
|
|
39
|
+
return list(
|
|
40
|
+
(
|
|
41
|
+
await session.execute(select(UserRole.role_id).where(UserRole.user_id == user.id))
|
|
42
|
+
).scalars()
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_oauth_provisioning_with_signup_closed_gets_no_role(users_app, users_db):
|
|
47
|
+
manager = _manager(users_app, users_db, allow_signup=False)
|
|
48
|
+
user = await _user(users_db)
|
|
49
|
+
await manager.on_after_register(user, _request(oauth=True))
|
|
50
|
+
assert await _roles(users_db, user) == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_oauth_provisioning_with_signup_open_gets_the_user_role(users_app, users_db):
|
|
54
|
+
manager = _manager(users_app, users_db, allow_signup=True)
|
|
55
|
+
user = await _user(users_db)
|
|
56
|
+
await manager.on_after_register(user, _request(oauth=True))
|
|
57
|
+
assert await _roles(users_db, user) == [USER_ROLE_ID]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_request_bound_registration_gets_the_user_role(users_app, users_db):
|
|
61
|
+
manager = _manager(users_app, users_db, allow_signup=True)
|
|
62
|
+
user = await _user(users_db)
|
|
63
|
+
await manager.on_after_register(user, _request(oauth=False))
|
|
64
|
+
assert await _roles(users_db, user) == [USER_ROLE_ID]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def test_a_deleted_user_role_is_not_recreated(users_app, users_db):
|
|
68
|
+
role = await users_db.get(Role, USER_ROLE_ID)
|
|
69
|
+
await users_db.delete(role)
|
|
70
|
+
await users_db.commit()
|
|
71
|
+
manager = _manager(users_app, users_db, allow_signup=True)
|
|
72
|
+
user = await _user(users_db)
|
|
73
|
+
await manager.on_after_register(user, _request(oauth=False))
|
|
74
|
+
assert await _roles(users_db, user) == []
|
|
75
|
+
assert await users_db.get(Role, USER_ROLE_ID) is None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Domain failures remain usable outside HTTP request handlers."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
from users.auth_local.lockout import too_many_attempts
|
|
7
|
+
from users.exceptions import UserNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_lockout_is_a_domain_error():
|
|
11
|
+
error = too_many_attempts(0)
|
|
12
|
+
assert not isinstance(error, HTTPException)
|
|
13
|
+
assert error.retry_after == 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_user_errors_share_a_module_base():
|
|
17
|
+
from users.exceptions import UsersError
|
|
18
|
+
|
|
19
|
+
assert isinstance(UserNotFoundError(uuid.uuid4()), UsersError)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_lockout_http_mapping_preserves_body_and_retry_after():
|
|
23
|
+
import httpx
|
|
24
|
+
from fastapi import FastAPI
|
|
25
|
+
from users.module import UsersModule
|
|
26
|
+
|
|
27
|
+
app = FastAPI()
|
|
28
|
+
UsersModule().register_exception_handlers(app)
|
|
29
|
+
|
|
30
|
+
@app.get("/locked")
|
|
31
|
+
async def locked():
|
|
32
|
+
raise too_many_attempts(0)
|
|
33
|
+
|
|
34
|
+
async with httpx.AsyncClient(
|
|
35
|
+
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
|
36
|
+
) as client:
|
|
37
|
+
response = await client.get("/locked")
|
|
38
|
+
assert response.status_code == 429
|
|
39
|
+
assert response.content == '{"detail":"Too many attempts — try again later"}'.encode()
|
|
40
|
+
assert response.headers["Retry-After"] == "1"
|
|
@@ -134,39 +134,6 @@ class TestOnAfterRegister:
|
|
|
134
134
|
assert event.user_id == fake_user.id
|
|
135
135
|
assert event.email == fake_user.email
|
|
136
136
|
|
|
137
|
-
@pytest.mark.anyio
|
|
138
|
-
async def test_request_bound_registration_gets_the_user_role(
|
|
139
|
-
self, manager, fake_user, fake_user_db
|
|
140
|
-
):
|
|
141
|
-
"""GH #488: a signup with no role holds no permissions at all."""
|
|
142
|
-
from users.models import UserRole
|
|
143
|
-
|
|
144
|
-
request = MagicMock()
|
|
145
|
-
request.state = MagicMock(spec=[]) # a local signup, not OAuth
|
|
146
|
-
request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
|
|
147
|
-
fake_user.is_verified = True
|
|
148
|
-
role = MagicMock(id=uuid.uuid4())
|
|
149
|
-
fake_user_db.session.execute = AsyncMock(
|
|
150
|
-
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=role))
|
|
151
|
-
)
|
|
152
|
-
await manager.on_after_register(fake_user, request)
|
|
153
|
-
|
|
154
|
-
(added,) = fake_user_db.session.add.call_args.args
|
|
155
|
-
assert isinstance(added, UserRole)
|
|
156
|
-
assert (added.user_id, added.role_id) == (fake_user.id, role.id)
|
|
157
|
-
|
|
158
|
-
@pytest.mark.anyio
|
|
159
|
-
async def test_a_deleted_user_role_is_not_recreated(self, manager, fake_user, fake_user_db):
|
|
160
|
-
"""An admin who removed the role meant it; a public signup must not undo that."""
|
|
161
|
-
request = MagicMock()
|
|
162
|
-
request.app.state.sm.event_bus = MagicMock(publish=AsyncMock())
|
|
163
|
-
fake_user.is_verified = True
|
|
164
|
-
fake_user_db.session.execute = AsyncMock(
|
|
165
|
-
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None))
|
|
166
|
-
)
|
|
167
|
-
await manager.on_after_register(fake_user, request)
|
|
168
|
-
fake_user_db.session.add.assert_not_called()
|
|
169
|
-
|
|
170
137
|
@pytest.mark.anyio
|
|
171
138
|
async def test_without_request_skips_publish(self, manager, fake_user, fake_user_db):
|
|
172
139
|
"""CLI/bootstrap and admin flows pass request=None — no crash, no role:
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Registration must release its connection before the durable role checkpoint."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_hosting.settings import Settings
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
9
|
+
from users.constants import USER_ROLE_NAME
|
|
10
|
+
from users.models import Role, User, UserRole
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def settings(settings: Settings, tmp_path) -> Settings:
|
|
15
|
+
database_url = settings.database_url
|
|
16
|
+
if database_url.startswith("sqlite"):
|
|
17
|
+
database_url = f"sqlite+aiosqlite:///{tmp_path / 'signup.db'}"
|
|
18
|
+
return settings.model_copy(
|
|
19
|
+
update={"database_url": database_url, "multi_tenant": False, "tenant_header": ""}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_signup_attaches_default_role_with_one_connection(app, client):
|
|
24
|
+
# Constrain registration after startup, which has its own DB operations.
|
|
25
|
+
db = app.state.sm.db
|
|
26
|
+
engine = create_async_engine(db.engine.url, pool_size=1, max_overflow=0)
|
|
27
|
+
await db.engine.dispose()
|
|
28
|
+
db.engine = engine
|
|
29
|
+
db.session_factory.configure(bind=engine)
|
|
30
|
+
app.state.users.settings = app.state.users.settings.model_copy(
|
|
31
|
+
update={"allow_signup": True, "require_verification": False}
|
|
32
|
+
)
|
|
33
|
+
email = "single-connection@example.com"
|
|
34
|
+
|
|
35
|
+
async with asyncio.timeout(3):
|
|
36
|
+
response = await client.post(
|
|
37
|
+
"/api/users/auth/register", json={"email": email, "password": "SecurePass1!"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
assert response.status_code == 201, response.text
|
|
41
|
+
assert response.json()["email"] == email
|
|
42
|
+
async with app.state.sm.db.session_factory() as session:
|
|
43
|
+
roles = (
|
|
44
|
+
(
|
|
45
|
+
await session.execute(
|
|
46
|
+
select(Role.name)
|
|
47
|
+
.join(UserRole, UserRole.role_id == Role.id)
|
|
48
|
+
.join(User, User.id == UserRole.user_id)
|
|
49
|
+
.where(User.email == email)
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
.scalars()
|
|
53
|
+
.all()
|
|
54
|
+
)
|
|
55
|
+
assert roles == [USER_ROLE_NAME]
|