simple-module-users 0.0.33__tar.gz → 0.0.35__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.33 → simple_module_users-0.0.35}/PKG-INFO +34 -6
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/README.md +28 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/package.json +3 -3
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/pyproject.toml +6 -6
- simple_module_users-0.0.35/tests/_demo_support.py +33 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/conftest.py +1 -1
- simple_module_users-0.0.35/tests/test_activity_summary.py +111 -0
- simple_module_users-0.0.35/tests/test_bearer_token_expiry.py +265 -0
- simple_module_users-0.0.35/tests/test_demo_account.py +182 -0
- simple_module_users-0.0.35/tests/test_demo_guard.py +178 -0
- simple_module_users-0.0.35/tests/test_demo_login.py +137 -0
- simple_module_users-0.0.35/tests/test_legacy_session_expiry.py +159 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_remember_me.py +6 -2
- simple_module_users-0.0.35/tests/test_revocation_broadcast.py +167 -0
- simple_module_users-0.0.35/tests/test_session_revocation.py +167 -0
- simple_module_users-0.0.35/tests/test_session_version_failopen.py +157 -0
- simple_module_users-0.0.35/tests/test_session_version_hardening.py +242 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_manager.py +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_role_model.py +17 -6
- simple_module_users-0.0.35/tests/test_user_state.py +48 -0
- simple_module_users-0.0.35/tests/test_users_shared_props.py +111 -0
- simple_module_users-0.0.35/tests-js/deriveState.test.ts +43 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UserRow.tsx +28 -8
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/recent_activity.py +73 -25
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/views.py +4 -2
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/audit.py +9 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/api.py +2 -2
- simple_module_users-0.0.35/users/auth_local/components/DemoSignIn.tsx +97 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/LoginForm.tsx +2 -9
- simple_module_users-0.0.35/users/auth_local/demo_api.py +94 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/self_account.py +38 -13
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/token_api.py +22 -3
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/views.py +19 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/backend.py +46 -19
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/constants.py +7 -0
- simple_module_users-0.0.35/users/demo.py +233 -0
- simple_module_users-0.0.35/users/demo_guard.py +122 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/locales/en.json +20 -2
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/manager.py +8 -0
- simple_module_users-0.0.35/users/models/access_token.py +69 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/refresh_token.py +7 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/module.py +40 -76
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/AcceptInvite.tsx +1 -6
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/ForgotPassword.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Login.tsx +53 -3
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Profile.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Register.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/ResetPassword.tsx +1 -6
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/AddPeople.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/Edit.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/Index.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/provider.py +64 -39
- simple_module_users-0.0.35/users/session_revocation.py +89 -0
- simple_module_users-0.0.35/users/session_version_cache.py +152 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/settings.py +66 -0
- simple_module_users-0.0.35/users/shared_props.py +49 -0
- simple_module_users-0.0.35/users/startup.py +159 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/state.py +6 -0
- simple_module_users-0.0.35/users/token_strategy.py +164 -0
- simple_module_users-0.0.33/tests/test_users_shared_props.py +0 -52
- simple_module_users-0.0.33/users/models/access_token.py +0 -33
- simple_module_users-0.0.33/users/session_version_cache.py +0 -84
- simple_module_users-0.0.33/users/shared_props.py +0 -29
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/.gitignore +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/LICENSE +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_views.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tsconfig.json +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/api.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/bulk_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/useUserRowActions.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/queries.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/resend_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/service.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/invite_preview.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/cli.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/deps.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/exceptions.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/health.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/middleware.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/_base.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/role.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/user.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/useEditUserForm.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/useUserActions.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/py.typed +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/setup.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.35
|
|
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
|
|
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: aiosmtplib>=3.0
|
|
25
25
|
Requires-Dist: cachetools>=5.3
|
|
26
26
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
27
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.35
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.35
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.35
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.35
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.35
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -53,6 +53,7 @@ Pre-wired into any app scaffolded with `smpy new`.
|
|
|
53
53
|
- `smpy users create-admin` CLI for ad-hoc admin creation.
|
|
54
54
|
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
55
55
|
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
|
|
56
|
+
- Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
|
|
56
57
|
|
|
57
58
|
## Usage
|
|
58
59
|
|
|
@@ -85,6 +86,33 @@ async def profile(user: CurrentUser):
|
|
|
85
86
|
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
|
|
86
87
|
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
|
|
87
88
|
|
|
89
|
+
## Demo mode
|
|
90
|
+
|
|
91
|
+
For hosting a public showcase instance. Set under **/admin/settings/ → Users**:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
demo_mode = true
|
|
95
|
+
demo_admin_email = demo-admin@example.com
|
|
96
|
+
demo_user_email = demo-user@example.com
|
|
97
|
+
demo_read_only = true # default — keep it on
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The sign-in card grows one button per configured account — **Explore as an
|
|
101
|
+
administrator** and **Explore as a standard user** — each posting to
|
|
102
|
+
`POST /api/users/auth/demo/{role}`. Neither account's password reaches the
|
|
103
|
+
browser. Two accounts because the admin surface is what the framework is for
|
|
104
|
+
and the end-user app is what you build with it; a visitor can switch between
|
|
105
|
+
them without signing out. Blank either email to offer only the other; that
|
|
106
|
+
closes its route too, not just its button.
|
|
107
|
+
|
|
108
|
+
Both accounts are seeded (and reconciled) at boot and on every settings
|
|
109
|
+
reload, and while `demo_read_only` is on, `DemoReadOnlyMiddleware` refuses
|
|
110
|
+
every unsafe HTTP method from either demo session — everyone else on the
|
|
111
|
+
instance is unaffected. A standing banner tells the visitor they are in a demo.
|
|
112
|
+
|
|
113
|
+
Full detail, including the `demo_read_only = false` warning, is in
|
|
114
|
+
[docs/modules/users.md](../../docs/modules/users.md#demo-mode-hosting-a-showcase-instance).
|
|
115
|
+
|
|
88
116
|
## Social sign-in (Google, GitHub, Microsoft, OIDC)
|
|
89
117
|
|
|
90
118
|
OAuth providers are configured in the admin UI at **/settings/modules → Users**
|
|
@@ -19,6 +19,7 @@ Pre-wired into any app scaffolded with `smpy new`.
|
|
|
19
19
|
- `smpy users create-admin` CLI for ad-hoc admin creation.
|
|
20
20
|
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
21
21
|
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
|
|
22
|
+
- Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
|
|
22
23
|
|
|
23
24
|
## Usage
|
|
24
25
|
|
|
@@ -51,6 +52,33 @@ async def profile(user: CurrentUser):
|
|
|
51
52
|
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
|
|
52
53
|
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
|
|
53
54
|
|
|
55
|
+
## Demo mode
|
|
56
|
+
|
|
57
|
+
For hosting a public showcase instance. Set under **/admin/settings/ → Users**:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
demo_mode = true
|
|
61
|
+
demo_admin_email = demo-admin@example.com
|
|
62
|
+
demo_user_email = demo-user@example.com
|
|
63
|
+
demo_read_only = true # default — keep it on
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The sign-in card grows one button per configured account — **Explore as an
|
|
67
|
+
administrator** and **Explore as a standard user** — each posting to
|
|
68
|
+
`POST /api/users/auth/demo/{role}`. Neither account's password reaches the
|
|
69
|
+
browser. Two accounts because the admin surface is what the framework is for
|
|
70
|
+
and the end-user app is what you build with it; a visitor can switch between
|
|
71
|
+
them without signing out. Blank either email to offer only the other; that
|
|
72
|
+
closes its route too, not just its button.
|
|
73
|
+
|
|
74
|
+
Both accounts are seeded (and reconciled) at boot and on every settings
|
|
75
|
+
reload, and while `demo_read_only` is on, `DemoReadOnlyMiddleware` refuses
|
|
76
|
+
every unsafe HTTP method from either demo session — everyone else on the
|
|
77
|
+
instance is unaffected. A standing banner tells the visitor they are in a demo.
|
|
78
|
+
|
|
79
|
+
Full detail, including the `demo_read_only = false` warning, is in
|
|
80
|
+
[docs/modules/users.md](../../docs/modules/users.md#demo-mode-hosting-a-showcase-instance).
|
|
81
|
+
|
|
54
82
|
## Social sign-in (Google, GitHub, Microsoft, OIDC)
|
|
55
83
|
|
|
56
84
|
OAuth providers are configured in the admin UI at **/settings/modules → Users**
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
"peerDependencies": {
|
|
7
7
|
"react": "^19.0.0",
|
|
8
8
|
"react-dom": "^19.0.0",
|
|
9
|
-
"@inertiajs/react": "^
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
10
|
"@simple-module-py/ui": "*"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@simple-module-py/tsconfig": "*"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"lucide-react": "^1.
|
|
17
|
-
"sonner": "^2.0.
|
|
16
|
+
"lucide-react": "^1.43.0",
|
|
17
|
+
"sonner": "^2.0.8"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.35"
|
|
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,11 +21,11 @@ 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.35",
|
|
25
|
+
"simple_module_db==0.0.35",
|
|
26
|
+
"simple_module_hosting==0.0.35",
|
|
27
|
+
"simple_module_settings==0.0.35",
|
|
28
|
+
"simple_module_auth==0.0.35",
|
|
29
29
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
30
30
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
31
31
|
# Bumping the major version requires re-checking those field names.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Fixtures for the demo-account tests, shared by the seeding and guard suites.
|
|
2
|
+
|
|
3
|
+
Loaded as a pytest plugin from ``conftest.py`` — same mechanism as
|
|
4
|
+
``_middleware_support``. Not named ``test_*`` so pytest does not collect it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
from _users_app_builders import _build_users_app
|
|
12
|
+
from users.demo import ensure_demo_users
|
|
13
|
+
|
|
14
|
+
DEMO_ADMIN_EMAIL = "demo-admin@example.com"
|
|
15
|
+
DEMO_USER_EMAIL = "demo-user@example.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
async def demo_app(monkeypatch):
|
|
20
|
+
"""A users app with demo mode on and both demo accounts seeded."""
|
|
21
|
+
application, ctx = await _build_users_app(monkeypatch, allow_signup=False)
|
|
22
|
+
application.state.users.settings.demo_mode = True
|
|
23
|
+
await ensure_demo_users(application)
|
|
24
|
+
yield application
|
|
25
|
+
await ctx.__aexit__(None, None, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
async def demo_client(demo_app):
|
|
30
|
+
"""Anonymous client against ``demo_app`` — it signs itself in via a button."""
|
|
31
|
+
transport = httpx.ASGITransport(app=demo_app)
|
|
32
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
33
|
+
yield client
|
|
@@ -162,4 +162,4 @@ async def users_db(users_app) -> AsyncGenerator[AsyncSession, None]:
|
|
|
162
162
|
|
|
163
163
|
# Fixtures consumed by the users.middleware unit tests live in
|
|
164
164
|
# _middleware_support.py (imported as a pytest plugin below).
|
|
165
|
-
pytest_plugins = ["_middleware_support"]
|
|
165
|
+
pytest_plugins = ["_middleware_support", "_demo_support"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Every activity line comes out of a catalog, not out of an f-string.
|
|
2
|
+
|
|
3
|
+
The card used to build its sentences in Python — ``f"{verb} {kind} {label}"``
|
|
4
|
+
from an English verb table — so the "Recent activity" list read as English in
|
|
5
|
+
every locale and no translation could reach it. Neither lint check could see
|
|
6
|
+
it: they only parse ``.tsx``.
|
|
7
|
+
|
|
8
|
+
These tests translate with a stub that echoes ``key(args)`` instead of copy, so
|
|
9
|
+
an assertion here fails the moment a sentence is assembled locally again — an
|
|
10
|
+
f-string would produce prose where the stub produces a key.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from simple_module_core import AuditLink, AuditLinkRegistry
|
|
18
|
+
from users.admin.recent_activity import _kind_of, _summarise
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _EchoTranslator:
|
|
22
|
+
"""Stands in for ``Translator``, rendering the key and its arguments."""
|
|
23
|
+
|
|
24
|
+
def t(self, key: str, **params: Any) -> str:
|
|
25
|
+
rendered = ", ".join(f"{name}={value}" for name, value in sorted(params.items()))
|
|
26
|
+
return f"{key}({rendered})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _KeylessTranslator(_EchoTranslator):
|
|
30
|
+
"""A translator with an empty catalog: ``t`` echoes the key back."""
|
|
31
|
+
|
|
32
|
+
def t(self, key: str, **params: Any) -> str:
|
|
33
|
+
return key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
T = _EchoTranslator()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestSummaryKeys:
|
|
40
|
+
def test_a_create_reads_from_the_created_key(self) -> None:
|
|
41
|
+
assert _summarise(T, "create", "setting", "smtp_host", []) == (
|
|
42
|
+
"users.recent_activity.summary.created(kind=setting, label=smtp_host)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def test_every_spelling_of_one_action_shares_a_key(self) -> None:
|
|
46
|
+
"""Writers disagree on "delete"/"deleted"; the reader should not."""
|
|
47
|
+
assert _summarise(T, "delete", "file", "x", []) == _summarise(T, "deleted", "file", "x", [])
|
|
48
|
+
|
|
49
|
+
def test_an_unknown_action_keeps_its_verb_as_an_argument(self) -> None:
|
|
50
|
+
"""Inventing a translation for a verb only one writer uses is a guess."""
|
|
51
|
+
assert _summarise(T, "archive", "file", "x", []) == (
|
|
52
|
+
"users.recent_activity.summary.other(action=Archive, kind=file, label=x)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_named_fields_are_one_argument_not_a_locally_joined_sentence(self) -> None:
|
|
56
|
+
summary = _summarise(T, "update", "user", "Ada", [{"field": "email"}, {"field": "roles"}])
|
|
57
|
+
assert summary == (
|
|
58
|
+
"users.recent_activity.summary.changed_fields(fields=email, roles, label=Ada)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_a_long_field_list_becomes_a_count(self) -> None:
|
|
62
|
+
changes = [{"field": name} for name in ("a", "b", "c", "d")]
|
|
63
|
+
assert _summarise(T, "update", "user", "Ada", changes) == (
|
|
64
|
+
"users.recent_activity.summary.changed_count(count=4, label=Ada)"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def test_an_update_with_no_recorded_fields_falls_back_to_the_plain_verb(self) -> None:
|
|
68
|
+
assert _summarise(T, "update", "user", "Ada", None) == (
|
|
69
|
+
"users.recent_activity.summary.updated(kind=user, label=Ada)"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def test_a_missing_catalog_entry_leaves_no_trailing_space(self) -> None:
|
|
73
|
+
"""The key echoes back verbatim; nothing here appends a stray gap."""
|
|
74
|
+
assert _summarise(_KeylessTranslator(), "create", "setting", "x", []) == (
|
|
75
|
+
"users.recent_activity.summary.created"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestKindOf:
|
|
80
|
+
def _registry(self, link: AuditLink) -> AuditLinkRegistry:
|
|
81
|
+
registry = AuditLinkRegistry()
|
|
82
|
+
registry.register(link)
|
|
83
|
+
return registry
|
|
84
|
+
|
|
85
|
+
def test_the_links_label_key_is_translated(self) -> None:
|
|
86
|
+
registry = self._registry(
|
|
87
|
+
AuditLink(
|
|
88
|
+
entity_type="Setting",
|
|
89
|
+
url_template="/s/{id}",
|
|
90
|
+
label="Setting",
|
|
91
|
+
label_key="settings.audit.setting",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
assert _kind_of(T, registry, "Setting") == "settings.audit.setting()"
|
|
95
|
+
|
|
96
|
+
def test_an_unresolved_key_falls_back_to_the_english_label(self) -> None:
|
|
97
|
+
registry = self._registry(
|
|
98
|
+
AuditLink(
|
|
99
|
+
entity_type="Setting",
|
|
100
|
+
url_template="/s/{id}",
|
|
101
|
+
label="Setting",
|
|
102
|
+
label_key="settings.audit.setting",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
assert _kind_of(_KeylessTranslator(), registry, "Setting") == "setting"
|
|
106
|
+
|
|
107
|
+
def test_a_type_no_module_claims_reads_as_its_class_name(self) -> None:
|
|
108
|
+
assert _kind_of(T, AuditLinkRegistry(), "Unclaimed") == "unclaimed"
|
|
109
|
+
|
|
110
|
+
def test_no_registry_at_all_is_survivable(self) -> None:
|
|
111
|
+
assert _kind_of(T, None, "Unclaimed") == "unclaimed"
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""A bearer credential lasts as long as it was issued for — not thirty days.
|
|
2
|
+
|
|
3
|
+
``_TOKEN_LIFETIME_SECONDS`` is one number for the whole process, because
|
|
4
|
+
``current_user`` resolves its strategy from the shared backend and cannot vary
|
|
5
|
+
it per request. It has to be the widest window any sign-in asks for, so every
|
|
6
|
+
other credential inherited it: an ordinary sign-in wrote an ``sm_auth`` cookie
|
|
7
|
+
whose ``Max-Age`` said fourteen days while the row behind it was accepted for
|
|
8
|
+
thirty, and ``/api/users/auth/token`` returned ``expires_in=900`` for a row good
|
|
9
|
+
for a month. ``Max-Age`` is browser-enforced only — a cookie lifted off disk is
|
|
10
|
+
replayed without one.
|
|
11
|
+
|
|
12
|
+
Each row now carries its own deadline and the ``session_version`` it was minted
|
|
13
|
+
under, and both bearer read paths enforce them: ``ExpiringDatabaseStrategy``
|
|
14
|
+
(which backs ``fastapi_users.current_user``) and ``UsersAuthProvider
|
|
15
|
+
._resolve_bearer`` (which backs ``AuthMiddleware``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import pytest
|
|
25
|
+
from sqlalchemy import select
|
|
26
|
+
from users.models import User, UserAccessToken
|
|
27
|
+
|
|
28
|
+
_LOGIN = "/api/users/auth/login"
|
|
29
|
+
_TOKEN = "/api/users/auth/token"
|
|
30
|
+
_ME = "/api/users/me"
|
|
31
|
+
_CREDS = {"username": "admin@example.com", "password": "AdminPass1!"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _tokens(app) -> list[UserAccessToken]:
|
|
35
|
+
async with app.state.sm.db.session_factory() as session:
|
|
36
|
+
return list((await session.execute(select(UserAccessToken))).scalars())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _only_token(app) -> UserAccessToken:
|
|
40
|
+
rows = await _tokens(app)
|
|
41
|
+
assert len(rows) == 1, f"expected exactly one access token, got {len(rows)}"
|
|
42
|
+
return rows[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _seconds_out(row: UserAccessToken) -> float:
|
|
46
|
+
"""How far ``expires_at`` sits from ``created_at``, in seconds."""
|
|
47
|
+
created, expires = row.created_at, row.expires_at
|
|
48
|
+
created = created.replace(tzinfo=UTC) if created.tzinfo is None else created
|
|
49
|
+
expires = expires.replace(tzinfo=UTC) if expires.tzinfo is None else expires
|
|
50
|
+
return (expires - created).total_seconds()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _bearer(app, token: str) -> httpx.AsyncClient:
|
|
54
|
+
"""A client presenting the row as an ``Authorization: Bearer`` header.
|
|
55
|
+
|
|
56
|
+
Read by ``UsersAuthProvider._resolve_bearer`` through ``AuthMiddleware``.
|
|
57
|
+
"""
|
|
58
|
+
return httpx.AsyncClient(
|
|
59
|
+
transport=httpx.ASGITransport(app=app),
|
|
60
|
+
base_url="http://testserver",
|
|
61
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _expire_all(app) -> None:
|
|
66
|
+
"""Push every row's deadline into the past, leaving ``created_at`` alone."""
|
|
67
|
+
async with app.state.sm.db.session_factory() as session:
|
|
68
|
+
for row in (await session.execute(select(UserAccessToken))).scalars():
|
|
69
|
+
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
|
70
|
+
await session.commit()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _expire(app, token: str) -> None:
|
|
74
|
+
"""Push a row's deadline into the past, leaving ``created_at`` alone.
|
|
75
|
+
|
|
76
|
+
The point of the test: the row is well inside the thirty-day read ceiling
|
|
77
|
+
and must still be refused, because the window it was *issued* for has run
|
|
78
|
+
out.
|
|
79
|
+
"""
|
|
80
|
+
async with app.state.sm.db.session_factory() as session:
|
|
81
|
+
row = await session.get(UserAccessToken, token)
|
|
82
|
+
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
|
83
|
+
await session.commit()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestTheRowLastsWhatWasPromised:
|
|
87
|
+
@pytest.mark.anyio
|
|
88
|
+
async def test_an_ordinary_sign_in_gets_the_cookie_window(self, users_app, anon_client):
|
|
89
|
+
"""Not the thirty-day ceiling. This is the whole bug: the `Max-Age` said
|
|
90
|
+
fourteen days and the row behind it was honoured for thirty."""
|
|
91
|
+
assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
|
|
92
|
+
|
|
93
|
+
settings = users_app.state.users.settings
|
|
94
|
+
assert _seconds_out(await _only_token(users_app)) == pytest.approx(
|
|
95
|
+
settings.cookie_max_age_seconds, abs=5
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@pytest.mark.anyio
|
|
99
|
+
async def test_keep_me_signed_in_gets_the_longer_window(self, users_app, anon_client):
|
|
100
|
+
"""The checkbox still has to mean what it says."""
|
|
101
|
+
assert (
|
|
102
|
+
await anon_client.post(_LOGIN, data={**_CREDS, "remember": "true"})
|
|
103
|
+
).status_code == 204
|
|
104
|
+
|
|
105
|
+
settings = users_app.state.users.settings
|
|
106
|
+
assert _seconds_out(await _only_token(users_app)) == pytest.approx(
|
|
107
|
+
settings.remember_me_max_age_seconds, abs=5
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@pytest.mark.anyio
|
|
111
|
+
async def test_the_token_endpoint_honours_its_own_expires_in(self, users_app, anon_client):
|
|
112
|
+
"""It advertised 15 minutes and minted a row good for a month, so a
|
|
113
|
+
client that honoured `expires_in` re-authenticated every 15 minutes
|
|
114
|
+
while the token it discarded stayed valid."""
|
|
115
|
+
resp = await anon_client.post(
|
|
116
|
+
_TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
117
|
+
)
|
|
118
|
+
assert resp.status_code == 200, resp.text
|
|
119
|
+
advertised = resp.json()["expires_in"]
|
|
120
|
+
|
|
121
|
+
settings = users_app.state.users.settings
|
|
122
|
+
assert advertised == settings.bearer_token_lifetime_seconds
|
|
123
|
+
assert _seconds_out(await _only_token(users_app)) == pytest.approx(advertised, abs=5)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestAnExpiredRowStopsAuthenticating:
|
|
127
|
+
@pytest.mark.anyio
|
|
128
|
+
async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client):
|
|
129
|
+
"""`fastapi_users.current_user`, via ExpiringDatabaseStrategy.
|
|
130
|
+
|
|
131
|
+
``/api/users/me`` is the probe because it depends on ``current_user``,
|
|
132
|
+
which is what reads the ``sm_auth`` row. The row stays well inside the
|
|
133
|
+
thirty-day read ceiling — only the deadline it was issued for has passed.
|
|
134
|
+
"""
|
|
135
|
+
assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
|
|
136
|
+
assert (await anon_client.get(_ME)).status_code == 200
|
|
137
|
+
|
|
138
|
+
await _expire_all(users_app)
|
|
139
|
+
assert (await anon_client.get(_ME)).status_code == 401
|
|
140
|
+
|
|
141
|
+
@pytest.mark.anyio
|
|
142
|
+
async def test_the_provider_path_refuses_it(self, users_app, anon_client):
|
|
143
|
+
"""`UsersAuthProvider._resolve_bearer`, via AuthMiddleware."""
|
|
144
|
+
from users.provider import UsersAuthProvider
|
|
145
|
+
|
|
146
|
+
resp = await anon_client.post(
|
|
147
|
+
_TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
148
|
+
)
|
|
149
|
+
token = resp.json()["access_token"]
|
|
150
|
+
scope = {"app": users_app}
|
|
151
|
+
|
|
152
|
+
provider = UsersAuthProvider()
|
|
153
|
+
assert await provider._resolve_bearer(scope, token) is not None
|
|
154
|
+
await _expire(users_app, token)
|
|
155
|
+
assert await provider._resolve_bearer(scope, token) is None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestAPasswordChangeStrandsBearerTokens:
|
|
159
|
+
"""`change_my_password` bumped `session_version` and stranded every session,
|
|
160
|
+
while every bearer token minted before it kept working — including any an
|
|
161
|
+
attacker who knew the old password had already collected."""
|
|
162
|
+
|
|
163
|
+
@pytest.mark.anyio
|
|
164
|
+
async def test_a_version_bump_strands_a_token_on_both_paths(self, users_app, anon_client):
|
|
165
|
+
from users.provider import UsersAuthProvider, forget_session_version
|
|
166
|
+
|
|
167
|
+
# One row per path: the sign-in writes the `sm_auth` row `current_user`
|
|
168
|
+
# reads, and `/auth/token` mints the one the Bearer header carries.
|
|
169
|
+
assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
|
|
170
|
+
minted = await anon_client.post(
|
|
171
|
+
_TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
172
|
+
)
|
|
173
|
+
token = minted.json()["access_token"]
|
|
174
|
+
provider = UsersAuthProvider()
|
|
175
|
+
scope = {"app": users_app}
|
|
176
|
+
|
|
177
|
+
assert (await anon_client.get(_ME)).status_code == 200
|
|
178
|
+
assert await provider._resolve_bearer(scope, token) is not None
|
|
179
|
+
|
|
180
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
181
|
+
user = (
|
|
182
|
+
await session.execute(select(User).where(User.email == "admin@example.com"))
|
|
183
|
+
).scalar_one()
|
|
184
|
+
user.session_version = int(user.session_version or 0) + 1
|
|
185
|
+
await session.commit()
|
|
186
|
+
forget_session_version(user.id)
|
|
187
|
+
|
|
188
|
+
assert (await anon_client.get(_ME)).status_code == 401
|
|
189
|
+
assert await provider._resolve_bearer(scope, token) is None
|
|
190
|
+
|
|
191
|
+
@pytest.mark.anyio
|
|
192
|
+
async def test_changing_the_password_deletes_the_rows(self, users_app, anon_client):
|
|
193
|
+
"""The stamped counter already strands them; deleting is what stops a
|
|
194
|
+
stolen token resolving a row at all, and stops the refresh tokens
|
|
195
|
+
minting fresh ones behind it."""
|
|
196
|
+
assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
|
|
197
|
+
assert await _tokens(users_app)
|
|
198
|
+
|
|
199
|
+
resp = await anon_client.post(
|
|
200
|
+
"/api/users/me/password",
|
|
201
|
+
json={"current_password": "AdminPass1!", "new_password": "NewAdminPass1!"},
|
|
202
|
+
)
|
|
203
|
+
assert resp.status_code == 204, resp.text
|
|
204
|
+
assert await _tokens(users_app) == []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestTheMintedCounterIsCurrent:
|
|
208
|
+
@pytest.mark.anyio
|
|
209
|
+
async def test_a_refresh_carries_the_accounts_counter(self, users_app, anon_client):
|
|
210
|
+
"""A refresh token outlives the access tokens it mints, so the account
|
|
211
|
+
is re-read rather than trusted from the row."""
|
|
212
|
+
resp = await anon_client.post(
|
|
213
|
+
_TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
214
|
+
)
|
|
215
|
+
refresh = resp.json()["refresh_token"]
|
|
216
|
+
|
|
217
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
218
|
+
user = (
|
|
219
|
+
await session.execute(select(User).where(User.email == "admin@example.com"))
|
|
220
|
+
).scalar_one()
|
|
221
|
+
user.session_version = 42
|
|
222
|
+
await session.commit()
|
|
223
|
+
|
|
224
|
+
rotated = await anon_client.post(
|
|
225
|
+
"/api/users/auth/token/refresh", json={"refresh_token": refresh}
|
|
226
|
+
)
|
|
227
|
+
assert rotated.status_code == 200, rotated.text
|
|
228
|
+
|
|
229
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
230
|
+
row = (
|
|
231
|
+
await session.execute(
|
|
232
|
+
select(UserAccessToken).where(
|
|
233
|
+
UserAccessToken.token == rotated.json()["access_token"]
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
).scalar_one()
|
|
237
|
+
assert row.session_version == 42
|
|
238
|
+
|
|
239
|
+
@pytest.mark.anyio
|
|
240
|
+
async def test_a_refresh_for_a_deactivated_account_is_refused(self, users_app, anon_client):
|
|
241
|
+
resp = await anon_client.post(
|
|
242
|
+
_TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
243
|
+
)
|
|
244
|
+
refresh = resp.json()["refresh_token"]
|
|
245
|
+
|
|
246
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
247
|
+
user = (
|
|
248
|
+
await session.execute(select(User).where(User.email == "admin@example.com"))
|
|
249
|
+
).scalar_one()
|
|
250
|
+
user.is_active = False
|
|
251
|
+
await session.commit()
|
|
252
|
+
|
|
253
|
+
rotated = await anon_client.post(
|
|
254
|
+
"/api/users/auth/token/refresh", json={"refresh_token": refresh}
|
|
255
|
+
)
|
|
256
|
+
assert rotated.status_code == 401
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TestTheModelDefault:
|
|
260
|
+
def test_a_row_built_directly_still_gets_a_deadline(self):
|
|
261
|
+
"""The mint paths always stamp one; the default keeps a caller that
|
|
262
|
+
builds the model by hand from minting a credential that never expires."""
|
|
263
|
+
row = UserAccessToken(token="t", user_id=uuid.uuid4())
|
|
264
|
+
assert row.expires_at is not None
|
|
265
|
+
assert _seconds_out(row) == pytest.approx(30 * 24 * 60 * 60, abs=5)
|