aso 0.2.0__py3-none-any.whl
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.
- aso/__init__.py +3 -0
- aso/auth/__init__.py +33 -0
- aso/auth/authenticatable.py +49 -0
- aso/auth/events.py +68 -0
- aso/auth/features.py +15 -0
- aso/auth/gate.py +187 -0
- aso/auth/guard.py +247 -0
- aso/auth/mail.py +61 -0
- aso/auth/middleware.py +281 -0
- aso/auth/password_broker.py +121 -0
- aso/cache/__init__.py +18 -0
- aso/cache/file.py +162 -0
- aso/cache/manager.py +169 -0
- aso/cache/memory.py +57 -0
- aso/cache/redis_store.py +55 -0
- aso/config/__init__.py +130 -0
- aso/console/__init__.py +25 -0
- aso/console/boot.py +88 -0
- aso/console/cli.py +207 -0
- aso/console/command.py +79 -0
- aso/console/commands/__init__.py +35 -0
- aso/console/commands/core.py +312 -0
- aso/console/commands/db.py +169 -0
- aso/console/commands/doctor.py +243 -0
- aso/console/commands/key.py +51 -0
- aso/console/commands/make.py +843 -0
- aso/console/commands/migrate.py +138 -0
- aso/console/commands/new.py +232 -0
- aso/console/commands/queue.py +110 -0
- aso/console/commands/route.py +44 -0
- aso/console/commands/routes_generate.py +80 -0
- aso/console/commands/schedule.py +92 -0
- aso/console/commands/seed.py +30 -0
- aso/console/commands/session.py +22 -0
- aso/console/commands/storage.py +22 -0
- aso/console/commands/test.py +62 -0
- aso/console/io.py +83 -0
- aso/console/migrations.py +399 -0
- aso/console/registry.py +231 -0
- aso/console/schedule.py +173 -0
- aso/console/signature.py +114 -0
- aso/console/stubs/agents_md.stub +1 -0
- aso/console/stubs/command.stub +1 -0
- aso/console/stubs/controller.stub +1 -0
- aso/console/stubs/event.stub +1 -0
- aso/console/stubs/factory.stub +1 -0
- aso/console/stubs/job.stub +1 -0
- aso/console/stubs/listener.stub +1 -0
- aso/console/stubs/mail.stub +1 -0
- aso/console/stubs/mail_view.stub +1 -0
- aso/console/stubs/middleware.stub +1 -0
- aso/console/stubs/model.stub +1 -0
- aso/console/stubs/page.stub +1 -0
- aso/console/stubs/policy.stub +1 -0
- aso/console/stubs/request.stub +1 -0
- aso/console/stubs/seeder.stub +1 -0
- aso/console/stubs/test.stub +1 -0
- aso/console/support.py +18 -0
- aso/context.py +67 -0
- aso/database/__init__.py +45 -0
- aso/database/builder.py +678 -0
- aso/database/db.py +110 -0
- aso/database/engine.py +217 -0
- aso/database/factory.py +196 -0
- aso/database/middleware.py +19 -0
- aso/database/model.py +560 -0
- aso/database/pagination.py +326 -0
- aso/database/relationships.py +165 -0
- aso/database/scope.py +174 -0
- aso/database/seeder.py +67 -0
- aso/database/types.py +160 -0
- aso/events/__init__.py +24 -0
- aso/events/event.py +128 -0
- aso/events/listener_job.py +50 -0
- aso/exceptions/__init__.py +45 -0
- aso/exceptions/debug.py +104 -0
- aso/exceptions/handler.py +366 -0
- aso/exceptions/types.py +210 -0
- aso/foundation/__init__.py +37 -0
- aso/foundation/application.py +227 -0
- aso/foundation/exceptions_config.py +49 -0
- aso/foundation/middleware_config.py +141 -0
- aso/foundation/paths.py +49 -0
- aso/foundation/stack.py +165 -0
- aso/http/__init__.py +36 -0
- aso/http/controller.py +35 -0
- aso/http/encoding.py +58 -0
- aso/http/method_spoofing.py +72 -0
- aso/http/middleware.py +149 -0
- aso/http/request.py +284 -0
- aso/http/responses.py +203 -0
- aso/inertia/__init__.py +28 -0
- aso/inertia/history.py +22 -0
- aso/inertia/json_encode.py +45 -0
- aso/inertia/middleware.py +108 -0
- aso/inertia/property.py +23 -0
- aso/inertia/props.py +150 -0
- aso/inertia/response.py +390 -0
- aso/inertia/root.py +40 -0
- aso/inertia/shared.py +18 -0
- aso/inertia/vite.py +150 -0
- aso/log/__init__.py +10 -0
- aso/log/manager.py +246 -0
- aso/log/testing.py +59 -0
- aso/mail/__init__.py +39 -0
- aso/mail/drivers.py +138 -0
- aso/mail/mailable.py +98 -0
- aso/mail/manager.py +215 -0
- aso/mail/queued_mail.py +24 -0
- aso/mail/rendering.py +101 -0
- aso/mail/resources/views/mail/layout.html +20 -0
- aso/queue/__init__.py +39 -0
- aso/queue/drivers.py +98 -0
- aso/queue/failed.py +100 -0
- aso/queue/job.py +90 -0
- aso/queue/manager.py +257 -0
- aso/queue/serialization.py +76 -0
- aso/queue/worker.py +117 -0
- aso/rate_limiting/__init__.py +20 -0
- aso/rate_limiting/limit.py +40 -0
- aso/rate_limiting/limiter.py +79 -0
- aso/rate_limiting/middleware.py +73 -0
- aso/routing/__init__.py +16 -0
- aso/routing/paths.py +118 -0
- aso/routing/pipeline.py +204 -0
- aso/routing/registry.py +701 -0
- aso/routing/signed.py +95 -0
- aso/routing/static.py +42 -0
- aso/routing/ts_routes.py +482 -0
- aso/routing/url.py +65 -0
- aso/security/__init__.py +9 -0
- aso/security/crypt.py +57 -0
- aso/security/hash.py +55 -0
- aso/security/keys.py +66 -0
- aso/session/__init__.py +40 -0
- aso/session/configuration.py +54 -0
- aso/session/drivers.py +239 -0
- aso/session/forgery.py +157 -0
- aso/session/middleware.py +161 -0
- aso/session/store.py +255 -0
- aso/skeleton/.env.example +26 -0
- aso/skeleton/.env.testing +20 -0
- aso/skeleton/.gitignore +24 -0
- aso/skeleton/AGENTS.md +49 -0
- aso/skeleton/LICENSES/laravel-react-starter-kit.md +35 -0
- aso/skeleton/app/__init__.py +0 -0
- aso/skeleton/app/actions/__init__.py +0 -0
- aso/skeleton/app/actions/auth/__init__.py +0 -0
- aso/skeleton/app/actions/auth/create_new_user.py +16 -0
- aso/skeleton/app/actions/auth/password_validation_rules.py +28 -0
- aso/skeleton/app/actions/auth/reset_user_password.py +15 -0
- aso/skeleton/app/console/__init__.py +0 -0
- aso/skeleton/app/console/commands/__init__.py +0 -0
- aso/skeleton/app/exceptions/__init__.py +0 -0
- aso/skeleton/app/http/__init__.py +0 -0
- aso/skeleton/app/http/controllers/__init__.py +0 -0
- aso/skeleton/app/http/controllers/auth/__init__.py +0 -0
- aso/skeleton/app/http/controllers/auth/authenticated_session_controller.py +32 -0
- aso/skeleton/app/http/controllers/auth/confirmable_password_controller.py +24 -0
- aso/skeleton/app/http/controllers/auth/email_verification_notification_controller.py +15 -0
- aso/skeleton/app/http/controllers/auth/email_verification_prompt_controller.py +15 -0
- aso/skeleton/app/http/controllers/auth/new_password_controller.py +27 -0
- aso/skeleton/app/http/controllers/auth/password_reset_link_controller.py +20 -0
- aso/skeleton/app/http/controllers/auth/registered_user_controller.py +24 -0
- aso/skeleton/app/http/controllers/auth/verify_email_controller.py +23 -0
- aso/skeleton/app/http/controllers/dashboard_controller.py +9 -0
- aso/skeleton/app/http/controllers/settings/__init__.py +0 -0
- aso/skeleton/app/http/controllers/settings/profile_controller.py +39 -0
- aso/skeleton/app/http/controllers/settings/security_controller.py +20 -0
- aso/skeleton/app/http/middleware/__init__.py +0 -0
- aso/skeleton/app/http/middleware/handle_appearance.py +25 -0
- aso/skeleton/app/http/middleware/handle_inertia_requests.py +17 -0
- aso/skeleton/app/http/requests/__init__.py +0 -0
- aso/skeleton/app/http/requests/auth/__init__.py +0 -0
- aso/skeleton/app/http/requests/auth/confirm_password_request.py +7 -0
- aso/skeleton/app/http/requests/auth/forgot_password_request.py +9 -0
- aso/skeleton/app/http/requests/auth/login_request.py +41 -0
- aso/skeleton/app/http/requests/auth/register_request.py +16 -0
- aso/skeleton/app/http/requests/auth/reset_password_request.py +16 -0
- aso/skeleton/app/http/requests/settings/__init__.py +0 -0
- aso/skeleton/app/http/requests/settings/delete_user_request.py +10 -0
- aso/skeleton/app/http/requests/settings/password_update_request.py +13 -0
- aso/skeleton/app/http/requests/settings/profile_update_request.py +13 -0
- aso/skeleton/app/jobs/__init__.py +0 -0
- aso/skeleton/app/listeners/__init__.py +0 -0
- aso/skeleton/app/listeners/send_email_verification_notification.py +14 -0
- aso/skeleton/app/mail/__init__.py +0 -0
- aso/skeleton/app/models/__init__.py +0 -0
- aso/skeleton/app/models/user.py +19 -0
- aso/skeleton/app/policies/__init__.py +0 -0
- aso/skeleton/bootstrap/app.py +30 -0
- aso/skeleton/components.json +21 -0
- aso/skeleton/config/app.py +12 -0
- aso/skeleton/config/auth.py +50 -0
- aso/skeleton/config/cache.py +14 -0
- aso/skeleton/config/database.py +34 -0
- aso/skeleton/config/filesystems.py +18 -0
- aso/skeleton/config/hashing.py +9 -0
- aso/skeleton/config/inertia.py +10 -0
- aso/skeleton/config/logging.py +18 -0
- aso/skeleton/config/mail.py +28 -0
- aso/skeleton/config/queue.py +14 -0
- aso/skeleton/config/session.py +19 -0
- aso/skeleton/database/__init__.py +0 -0
- aso/skeleton/database/factories/__init__.py +0 -0
- aso/skeleton/database/factories/user_factory.py +22 -0
- aso/skeleton/database/migrations/2026_01_01_000000_create_users_table.py +62 -0
- aso/skeleton/database/migrations/__init__.py +0 -0
- aso/skeleton/database/seeders/__init__.py +0 -0
- aso/skeleton/database/seeders/database_seeder.py +8 -0
- aso/skeleton/package-lock.json +2625 -0
- aso/skeleton/package.json +42 -0
- aso/skeleton/public/.gitkeep +0 -0
- aso/skeleton/public/favicon.svg +12 -0
- aso/skeleton/pyproject.toml +45 -0
- aso/skeleton/resources/css/app.css +138 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/authenticated_session_controller.ts +54 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/confirmable_password_controller.ts +39 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/email_verification_notification_controller.ts +23 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/email_verification_prompt_controller.ts +22 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/new_password_controller.ts +42 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/password_reset_link_controller.ts +39 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/registered_user_controller.ts +39 -0
- aso/skeleton/resources/js/actions/app/http/controllers/auth/verify_email_controller.ts +25 -0
- aso/skeleton/resources/js/actions/app/http/controllers/dashboard_controller.ts +24 -0
- aso/skeleton/resources/js/actions/app/http/controllers/settings/profile_controller.ts +54 -0
- aso/skeleton/resources/js/actions/app/http/controllers/settings/security_controller.ts +39 -0
- aso/skeleton/resources/js/app.tsx +39 -0
- aso/skeleton/resources/js/components/app-content.tsx +23 -0
- aso/skeleton/resources/js/components/app-header.tsx +249 -0
- aso/skeleton/resources/js/components/app-logo-icon.tsx +16 -0
- aso/skeleton/resources/js/components/app-logo.tsx +21 -0
- aso/skeleton/resources/js/components/app-shell.tsx +22 -0
- aso/skeleton/resources/js/components/app-sidebar-header.tsx +19 -0
- aso/skeleton/resources/js/components/app-sidebar.tsx +66 -0
- aso/skeleton/resources/js/components/appearance-tabs.tsx +47 -0
- aso/skeleton/resources/js/components/breadcrumbs.tsx +51 -0
- aso/skeleton/resources/js/components/delete-user.tsx +122 -0
- aso/skeleton/resources/js/components/heading.tsx +27 -0
- aso/skeleton/resources/js/components/input-error.tsx +18 -0
- aso/skeleton/resources/js/components/nav-footer.tsx +50 -0
- aso/skeleton/resources/js/components/nav-main.tsx +37 -0
- aso/skeleton/resources/js/components/nav-user.tsx +59 -0
- aso/skeleton/resources/js/components/password-input.tsx +38 -0
- aso/skeleton/resources/js/components/text-link.tsx +24 -0
- aso/skeleton/resources/js/components/ui/alert.tsx +67 -0
- aso/skeleton/resources/js/components/ui/avatar.tsx +52 -0
- aso/skeleton/resources/js/components/ui/breadcrumb.tsx +110 -0
- aso/skeleton/resources/js/components/ui/button.tsx +59 -0
- aso/skeleton/resources/js/components/ui/card.tsx +69 -0
- aso/skeleton/resources/js/components/ui/checkbox.tsx +31 -0
- aso/skeleton/resources/js/components/ui/dialog.tsx +134 -0
- aso/skeleton/resources/js/components/ui/dropdown-menu.tsx +256 -0
- aso/skeleton/resources/js/components/ui/icon.tsx +15 -0
- aso/skeleton/resources/js/components/ui/input.tsx +22 -0
- aso/skeleton/resources/js/components/ui/label.tsx +23 -0
- aso/skeleton/resources/js/components/ui/navigation-menu.tsx +169 -0
- aso/skeleton/resources/js/components/ui/placeholder-pattern.tsx +21 -0
- aso/skeleton/resources/js/components/ui/separator.tsx +27 -0
- aso/skeleton/resources/js/components/ui/sheet.tsx +138 -0
- aso/skeleton/resources/js/components/ui/sidebar.tsx +720 -0
- aso/skeleton/resources/js/components/ui/skeleton.tsx +14 -0
- aso/skeleton/resources/js/components/ui/sonner.tsx +28 -0
- aso/skeleton/resources/js/components/ui/spinner.tsx +17 -0
- aso/skeleton/resources/js/components/ui/tooltip.tsx +56 -0
- aso/skeleton/resources/js/components/user-info.tsx +33 -0
- aso/skeleton/resources/js/components/user-menu-content.tsx +65 -0
- aso/skeleton/resources/js/hooks/use-appearance.tsx +116 -0
- aso/skeleton/resources/js/hooks/use-current-url.ts +84 -0
- aso/skeleton/resources/js/hooks/use-flash-toast.ts +30 -0
- aso/skeleton/resources/js/hooks/use-initials.tsx +27 -0
- aso/skeleton/resources/js/hooks/use-mobile-navigation.ts +11 -0
- aso/skeleton/resources/js/hooks/use-mobile.tsx +37 -0
- aso/skeleton/resources/js/layouts/app/app-header-layout.tsx +17 -0
- aso/skeleton/resources/js/layouts/app/app-sidebar-layout.tsx +21 -0
- aso/skeleton/resources/js/layouts/app-layout.tsx +17 -0
- aso/skeleton/resources/js/layouts/auth/auth-card-layout.tsx +49 -0
- aso/skeleton/resources/js/layouts/auth/auth-simple-layout.tsx +39 -0
- aso/skeleton/resources/js/layouts/auth/auth-split-layout.tsx +45 -0
- aso/skeleton/resources/js/layouts/auth-layout.tsx +18 -0
- aso/skeleton/resources/js/layouts/settings/layout.tsx +79 -0
- aso/skeleton/resources/js/lib/utils.ts +13 -0
- aso/skeleton/resources/js/pages/auth/confirm-password.tsx +54 -0
- aso/skeleton/resources/js/pages/auth/forgot-password.tsx +70 -0
- aso/skeleton/resources/js/pages/auth/login.tsx +116 -0
- aso/skeleton/resources/js/pages/auth/register.tsx +122 -0
- aso/skeleton/resources/js/pages/auth/reset-password.tsx +98 -0
- aso/skeleton/resources/js/pages/auth/verify-email.tsx +47 -0
- aso/skeleton/resources/js/pages/dashboard.tsx +38 -0
- aso/skeleton/resources/js/pages/error.tsx +46 -0
- aso/skeleton/resources/js/pages/settings/appearance.tsx +34 -0
- aso/skeleton/resources/js/pages/settings/profile.tsx +134 -0
- aso/skeleton/resources/js/pages/settings/security.tsx +135 -0
- aso/skeleton/resources/js/pages/welcome.tsx +96 -0
- aso/skeleton/resources/js/routes/appearance/index.ts +20 -0
- aso/skeleton/resources/js/routes/index.ts +79 -0
- aso/skeleton/resources/js/routes/login/index.ts +19 -0
- aso/skeleton/resources/js/routes/password/confirm/index.ts +19 -0
- aso/skeleton/resources/js/routes/password/index.ts +81 -0
- aso/skeleton/resources/js/routes/profile/index.ts +48 -0
- aso/skeleton/resources/js/routes/register/index.ts +19 -0
- aso/skeleton/resources/js/routes/security/index.ts +20 -0
- aso/skeleton/resources/js/routes/user-password/index.ts +19 -0
- aso/skeleton/resources/js/routes/verification/index.ts +52 -0
- aso/skeleton/resources/js/types/auth.ts +16 -0
- aso/skeleton/resources/js/types/global.d.ts +32 -0
- aso/skeleton/resources/js/types/index.ts +5 -0
- aso/skeleton/resources/js/types/navigation.ts +16 -0
- aso/skeleton/resources/js/types/ui.ts +23 -0
- aso/skeleton/resources/js/types/vite-env.d.ts +1 -0
- aso/skeleton/resources/js/wayfinder/index.ts +150 -0
- aso/skeleton/resources/views/app.html +27 -0
- aso/skeleton/resources/views/mail/layout.html +20 -0
- aso/skeleton/routes/api.py +7 -0
- aso/skeleton/routes/auth.py +56 -0
- aso/skeleton/routes/console.py +1 -0
- aso/skeleton/routes/settings.py +27 -0
- aso/skeleton/routes/web.py +15 -0
- aso/skeleton/storage/app/public/.gitkeep +0 -0
- aso/skeleton/storage/framework/cache/.gitkeep +0 -0
- aso/skeleton/storage/logs/.gitkeep +0 -0
- aso/skeleton/tests/architecture/test_architecture.py +5 -0
- aso/skeleton/tests/conftest.py +16 -0
- aso/skeleton/tests/feature/auth/test_authentication.py +45 -0
- aso/skeleton/tests/feature/auth/test_email_verification.py +48 -0
- aso/skeleton/tests/feature/auth/test_password_confirmation.py +20 -0
- aso/skeleton/tests/feature/auth/test_password_reset.py +56 -0
- aso/skeleton/tests/feature/auth/test_registration.py +38 -0
- aso/skeleton/tests/feature/settings/test_password_update.py +38 -0
- aso/skeleton/tests/feature/settings/test_profile_update.py +61 -0
- aso/skeleton/tsconfig.json +20 -0
- aso/skeleton/vite/aso.ts +132 -0
- aso/skeleton/vite.config.ts +29 -0
- aso/storage/__init__.py +19 -0
- aso/storage/manager.py +167 -0
- aso/support/__init__.py +7 -0
- aso/support/str_helpers.py +30 -0
- aso/testing/__init__.py +31 -0
- aso/testing/arch/__init__.py +12 -0
- aso/testing/arch/_ast_utils.py +223 -0
- aso/testing/arch/_preset.py +411 -0
- aso/testing/arch/_selection.py +429 -0
- aso/testing/client.py +305 -0
- aso/testing/database.py +90 -0
- aso/testing/inertia_assert.py +235 -0
- aso/testing/options.py +27 -0
- aso/testing/plugin.py +164 -0
- aso/testing/response.py +358 -0
- aso/validation/__init__.py +33 -0
- aso/validation/form_request.py +259 -0
- aso/validation/input.py +100 -0
- aso/validation/messages.py +218 -0
- aso/validation/rules.py +247 -0
- aso/validation/uploaded_file.py +82 -0
- aso-0.2.0.dist-info/METADATA +100 -0
- aso-0.2.0.dist-info/RECORD +358 -0
- aso-0.2.0.dist-info/WHEEL +4 -0
- aso-0.2.0.dist-info/entry_points.txt +5 -0
aso/__init__.py
ADDED
aso/auth/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Authentication and authorization (T-38, T-39, T-40).
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from aso.auth import auth, Gate, Authenticatable, MustVerifyEmail
|
|
5
|
+
from aso.auth.events import Registered, Login, Logout, Failed, Lockout
|
|
6
|
+
|
|
7
|
+
class User(MustVerifyEmail, Authenticatable, Model):
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
if await auth().attempt({"email": email, "password": password}):
|
|
11
|
+
...
|
|
12
|
+
```
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from aso.auth.authenticatable import Authenticatable, MustVerifyEmail
|
|
18
|
+
from aso.auth.features import features
|
|
19
|
+
from aso.auth.gate import Gate
|
|
20
|
+
from aso.auth.guard import auth
|
|
21
|
+
from aso.auth.password_broker import password_broker
|
|
22
|
+
from aso.exceptions.types import AuthenticationException, AuthorizationException
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Authenticatable",
|
|
26
|
+
"AuthenticationException",
|
|
27
|
+
"AuthorizationException",
|
|
28
|
+
"Gate",
|
|
29
|
+
"MustVerifyEmail",
|
|
30
|
+
"auth",
|
|
31
|
+
"features",
|
|
32
|
+
"password_broker",
|
|
33
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""`Authenticatable` and `MustVerifyEmail` mixins (T-38, T-39).
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
class User(MustVerifyEmail, Authenticatable, Model):
|
|
5
|
+
...
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
Mix them in *before* `Model` so their defaults (like `__hidden__`) lose to anything the model
|
|
9
|
+
itself declares, matching normal Python MRO.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__all__ = ["Authenticatable", "MustVerifyEmail"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Authenticatable:
|
|
21
|
+
"""Adds `can()`/`cannot()` (T-39) and hides `password`/`remember_token` by default."""
|
|
22
|
+
|
|
23
|
+
__hidden__: tuple[str, ...] = ("password", "remember_token")
|
|
24
|
+
|
|
25
|
+
async def can(self, ability: str, *args: Any) -> bool:
|
|
26
|
+
from aso.auth.gate import Gate
|
|
27
|
+
|
|
28
|
+
return await Gate.for_user(self).allows(ability, *args)
|
|
29
|
+
|
|
30
|
+
async def cannot(self, ability: str, *args: Any) -> bool:
|
|
31
|
+
return not await self.can(ability, *args)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MustVerifyEmail:
|
|
35
|
+
"""Turns on email verification for a user model (T-38)."""
|
|
36
|
+
|
|
37
|
+
def has_verified_email(self) -> bool:
|
|
38
|
+
return getattr(self, "email_verified_at", None) is not None
|
|
39
|
+
|
|
40
|
+
async def mark_email_as_verified(self) -> bool:
|
|
41
|
+
self.email_verified_at = datetime.now(UTC)
|
|
42
|
+
await self.save() # type: ignore[attr-defined]
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
async def send_email_verification_notification(self) -> None:
|
|
46
|
+
from aso.auth.mail import VerifyEmailMail
|
|
47
|
+
from aso.mail import Mail
|
|
48
|
+
|
|
49
|
+
await Mail.to(self.email).send(VerifyEmailMail(self)) # type: ignore[attr-defined]
|
aso/auth/events.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Authentication events (T-38, T-46): `from aso.auth.events import ...`.
|
|
2
|
+
|
|
3
|
+
These carry the `User` model, so **their listeners cannot be queued** (`aso.events.Event.
|
|
4
|
+
dispatch()` raises `JobNotSerializable` before running anything if any listener sets
|
|
5
|
+
`queued = True`, T-46). `Failed.credentials` never contains the password; `Lockout` carries the
|
|
6
|
+
email and IP instead of the whole request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from aso.events import Event
|
|
14
|
+
|
|
15
|
+
__all__ = ["Failed", "Lockout", "Login", "Logout", "PasswordReset", "Registered", "Verified"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Registered(Event):
|
|
19
|
+
"""A user registered (T-38). Carries `user`."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, user: Any) -> None:
|
|
22
|
+
self.user = user
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Verified(Event):
|
|
26
|
+
"""A user's email address was verified (T-38). Carries `user`."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, user: Any) -> None:
|
|
29
|
+
self.user = user
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PasswordReset(Event):
|
|
33
|
+
"""A user reset their password through the password broker (T-38). Carries `user`."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, user: Any) -> None:
|
|
36
|
+
self.user = user
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Login(Event):
|
|
40
|
+
"""A user logged in (T-38). Carries `user` and whether it was a "remember me" login."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, user: Any, remember: bool = False) -> None:
|
|
43
|
+
self.user = user
|
|
44
|
+
self.remember = remember
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Logout(Event):
|
|
48
|
+
"""A user logged out (T-38). Carries `user`."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, user: Any) -> None:
|
|
51
|
+
self.user = user
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Failed(Event):
|
|
55
|
+
"""`auth().attempt()`/`.validate()` rejected credentials (T-38). `user` is `None` when no
|
|
56
|
+
matching account was found. `credentials` never includes the password."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, user: Any, credentials: dict[str, Any]) -> None:
|
|
59
|
+
self.user = user
|
|
60
|
+
self.credentials = {k: v for k, v in credentials.items() if k != "password"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Lockout(Event):
|
|
64
|
+
"""The login rate limit was hit (T-38). Carries `email` and `ip` (not the whole request)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, email: str, ip: str | None) -> None:
|
|
67
|
+
self.email = email
|
|
68
|
+
self.ip = ip
|
aso/auth/features.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""`features.enabled(name)` (T-38): toggle built-in auth flows via `config("auth.features")`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["features"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Features:
|
|
9
|
+
def enabled(self, name: str) -> bool:
|
|
10
|
+
from aso.config import config
|
|
11
|
+
|
|
12
|
+
return name in (config("auth.features") or [])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
features = _Features()
|
aso/auth/gate.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""`Gate` (T-39, D-030): `define`/`allows`/`denies`/`policy`/`authorize`, policy `before()`.
|
|
2
|
+
|
|
3
|
+
Policies are plain classes (no base class) found by convention: `app.models.post.Post` ->
|
|
4
|
+
`app.policies.post_policy.PostPolicy`. An explicit `Gate.policy(Post, PostPolicy)` mapping wins
|
|
5
|
+
over the convention. Guests are denied without calling the policy (or gate) unless the ability
|
|
6
|
+
method's `user` parameter is annotated `User | None` (Laravel's rule).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import inspect
|
|
13
|
+
import re
|
|
14
|
+
import types
|
|
15
|
+
import typing
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any, ClassVar
|
|
18
|
+
|
|
19
|
+
from aso.exceptions.types import AuthorizationException
|
|
20
|
+
|
|
21
|
+
__all__ = ["AuthorizationException", "Gate"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _snake(name: str) -> str:
|
|
25
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _policy_module_for(model_cls: type) -> str:
|
|
29
|
+
"""`app.models.post` -> `app.policies.post_policy` (D-030's convention)."""
|
|
30
|
+
module = model_cls.__module__
|
|
31
|
+
if ".models." in module:
|
|
32
|
+
module = module.replace(".models.", ".policies.", 1)
|
|
33
|
+
elif module.startswith("models."):
|
|
34
|
+
module = "policies." + module[len("models.") :]
|
|
35
|
+
head, _, leaf = module.rpartition(".")
|
|
36
|
+
leaf = f"{leaf}_policy" if leaf else f"{_snake(model_cls.__name__)}_policy"
|
|
37
|
+
return f"{head}.{leaf}" if head else leaf
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _allows_none(annotation: Any) -> bool:
|
|
41
|
+
if annotation is inspect.Parameter.empty:
|
|
42
|
+
return False
|
|
43
|
+
if annotation is type(None):
|
|
44
|
+
return True
|
|
45
|
+
origin = typing.get_origin(annotation)
|
|
46
|
+
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
|
47
|
+
return type(None) in typing.get_args(annotation)
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _accepts_guest(callback: Callable[..., Any]) -> bool:
|
|
52
|
+
"""Whether `callback`'s first (non-`self`) parameter is annotated to allow `None`.
|
|
53
|
+
|
|
54
|
+
Resolves only *that* parameter's annotation (not `typing.get_type_hints(callback)` on the
|
|
55
|
+
whole signature): a later parameter referencing a name the callback's module hasn't imported
|
|
56
|
+
(or, in tests, a class defined locally) must not make this conservative by accident.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
sig = inspect.signature(callback)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return False
|
|
62
|
+
params = [p for p in sig.parameters.values() if p.name != "self"]
|
|
63
|
+
if not params:
|
|
64
|
+
return False
|
|
65
|
+
annotation = params[0].annotation
|
|
66
|
+
if isinstance(annotation, str):
|
|
67
|
+
try:
|
|
68
|
+
annotation = eval(annotation, getattr(callback, "__globals__", {}), None) # noqa: S307
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
return _allows_none(annotation)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _call(callback: Callable[..., Any], *args: Any) -> Any:
|
|
75
|
+
result = callback(*args)
|
|
76
|
+
if inspect.isawaitable(result):
|
|
77
|
+
result = await result
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _UserGate:
|
|
82
|
+
"""`Gate.for_user(user)`: check abilities for a specific user (`Authenticatable.can()`)."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, user: Any) -> None:
|
|
85
|
+
self._user = user
|
|
86
|
+
|
|
87
|
+
async def allows(self, ability: str, *args: Any) -> bool:
|
|
88
|
+
return await Gate._check(self._user, ability, args)
|
|
89
|
+
|
|
90
|
+
async def denies(self, ability: str, *args: Any) -> bool:
|
|
91
|
+
return not await self.allows(ability, *args)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Gate:
|
|
95
|
+
"""`Gate.define/allows/denies/policy/authorize` (T-39). Classmethods, like Laravel's facade."""
|
|
96
|
+
|
|
97
|
+
_gates: ClassVar[dict[str, Callable[..., Any]]] = {}
|
|
98
|
+
_policies: ClassVar[dict[type, type]] = {}
|
|
99
|
+
_policy_cache: ClassVar[dict[type, type | None]] = {}
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def define(cls, ability: str, callback: Callable[..., Any]) -> None:
|
|
103
|
+
cls._gates[ability] = callback
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def policy(cls, model_cls: type, policy_cls: type) -> None:
|
|
107
|
+
cls._policies[model_cls] = policy_cls
|
|
108
|
+
cls._policy_cache.pop(model_cls, None)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def for_user(cls, user: Any) -> _UserGate:
|
|
112
|
+
return _UserGate(user)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
async def allows(cls, ability: str, *args: Any) -> bool:
|
|
116
|
+
from aso.auth.guard import auth
|
|
117
|
+
|
|
118
|
+
return await cls._check(auth().user(), ability, args)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
async def denies(cls, ability: str, *args: Any) -> bool:
|
|
122
|
+
return not await cls.allows(ability, *args)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
async def authorize(cls, ability: str, *args: Any) -> None:
|
|
126
|
+
if not await cls.allows(ability, *args):
|
|
127
|
+
raise AuthorizationException()
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------ resolution
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def _resolve_policy(cls, model_cls: type) -> type | None:
|
|
133
|
+
explicit = cls._policies.get(model_cls)
|
|
134
|
+
if explicit is not None:
|
|
135
|
+
return explicit
|
|
136
|
+
if model_cls in cls._policy_cache:
|
|
137
|
+
return cls._policy_cache[model_cls]
|
|
138
|
+
policy_cls: type | None = None
|
|
139
|
+
try:
|
|
140
|
+
module = importlib.import_module(_policy_module_for(model_cls))
|
|
141
|
+
policy_cls = getattr(module, f"{model_cls.__name__}Policy", None)
|
|
142
|
+
except ImportError:
|
|
143
|
+
policy_cls = None
|
|
144
|
+
cls._policy_cache[model_cls] = policy_cls
|
|
145
|
+
return policy_cls
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
async def _check(cls, user: Any, ability: str, args: tuple[Any, ...]) -> bool:
|
|
149
|
+
if ability in cls._gates:
|
|
150
|
+
callback = cls._gates[ability]
|
|
151
|
+
if user is None and not _accepts_guest(callback):
|
|
152
|
+
return False
|
|
153
|
+
return bool(await _call(callback, user, *args))
|
|
154
|
+
|
|
155
|
+
subject = args[0] if args else None
|
|
156
|
+
model_cls = subject if isinstance(subject, type) else (type(subject) if subject is not None else None)
|
|
157
|
+
if model_cls is None:
|
|
158
|
+
return False
|
|
159
|
+
policy_cls = cls._resolve_policy(model_cls)
|
|
160
|
+
if policy_cls is None:
|
|
161
|
+
return False
|
|
162
|
+
method = getattr(policy_cls, ability, None)
|
|
163
|
+
if method is None or not callable(method):
|
|
164
|
+
return False
|
|
165
|
+
if user is None and not _accepts_guest(method):
|
|
166
|
+
return False
|
|
167
|
+
policy = policy_cls()
|
|
168
|
+
before = getattr(policy, "before", None)
|
|
169
|
+
if before is not None:
|
|
170
|
+
result = await _call(before, user, ability)
|
|
171
|
+
if result is True:
|
|
172
|
+
return True
|
|
173
|
+
if result is False:
|
|
174
|
+
return False
|
|
175
|
+
bound = getattr(policy, ability)
|
|
176
|
+
# `authorize("create", Post)` (a bare class, no instance yet): nothing to pass besides
|
|
177
|
+
# `user` -- Laravel's `create(User $user)` takes no second parameter either. An instance
|
|
178
|
+
# (or several arguments) is forwarded as given: `authorize("update", post)` ->
|
|
179
|
+
# `update(user, post)`.
|
|
180
|
+
call_args = () if len(args) == 1 and isinstance(args[0], type) else args
|
|
181
|
+
return bool(await _call(bound, user, *call_args))
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def _reset_for_tests(cls) -> None:
|
|
185
|
+
cls._gates = {}
|
|
186
|
+
cls._policies = {}
|
|
187
|
+
cls._policy_cache = {}
|
aso/auth/guard.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""`auth()`: the session guard (T-38).
|
|
2
|
+
|
|
3
|
+
`user()`/`id()`/`check()`/`guest()`/`via_remember()` are synchronous: the user is loaded once per
|
|
4
|
+
web request by `AuthenticateSession` (`middleware.py`) and cached on `context.current().user`.
|
|
5
|
+
Everything that may touch the database or hash a password is `async`.
|
|
6
|
+
|
|
7
|
+
`login()`/`logout()` do **not** call `session().regenerate()`/`.invalidate()` themselves (T-38:
|
|
8
|
+
"Always call `session().regenerate()` after `login()` and `session().invalidate()` after
|
|
9
|
+
`logout()`. The kit's controllers do this."); the one exception is a "remember me" cookie login,
|
|
10
|
+
which `AuthenticateSession` regenerates itself since there is no controller in that path.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import secrets
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from aso import context
|
|
20
|
+
from aso.exceptions.types import AuthenticationException, AuthorizationException
|
|
21
|
+
|
|
22
|
+
__all__ = ["AuthenticationException", "AuthorizationException", "auth"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def guard_name() -> str:
|
|
26
|
+
from aso.config import config
|
|
27
|
+
|
|
28
|
+
return config("auth.defaults.guard", "web")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def session_key() -> str:
|
|
32
|
+
return f"login_{guard_name()}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def password_hash_key() -> str:
|
|
36
|
+
return f"password_hash_{guard_name()}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def remember_cookie_name() -> str:
|
|
40
|
+
return f"remember_{guard_name()}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _import_path(path: str) -> Any:
|
|
44
|
+
import importlib
|
|
45
|
+
|
|
46
|
+
module_path, _, attr = path.rpartition(".")
|
|
47
|
+
module = importlib.import_module(module_path)
|
|
48
|
+
return getattr(module, attr)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _user_model() -> type:
|
|
52
|
+
"""The user model class, resolved from the guard's provider (`config/auth.py`, T-38)."""
|
|
53
|
+
from aso.config import config
|
|
54
|
+
|
|
55
|
+
guard = guard_name()
|
|
56
|
+
provider = config(f"auth.guards.{guard}.provider", "users")
|
|
57
|
+
path = config(f"auth.providers.{provider}.model", "app.models.user.User")
|
|
58
|
+
return _import_path(path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_https() -> bool:
|
|
62
|
+
from aso.config import config
|
|
63
|
+
|
|
64
|
+
return str(config("app.url") or "").startswith("https://")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_set_cookie(name: str, value: str, *, max_age: int) -> str:
|
|
68
|
+
from starlette.responses import Response as StarletteResponse
|
|
69
|
+
|
|
70
|
+
resp = StarletteResponse()
|
|
71
|
+
resp.set_cookie(key=name, value=value, max_age=max_age, path="/", httponly=True, samesite="lax", secure=_is_https())
|
|
72
|
+
for key, raw in resp.raw_headers:
|
|
73
|
+
if key == b"set-cookie":
|
|
74
|
+
return raw.decode("latin-1")
|
|
75
|
+
raise RuntimeError("Could not build a Set-Cookie header.") # pragma: no cover - defensive
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_delete_cookie(name: str) -> str:
|
|
79
|
+
from starlette.responses import Response as StarletteResponse
|
|
80
|
+
|
|
81
|
+
resp = StarletteResponse()
|
|
82
|
+
resp.delete_cookie(key=name, path="/")
|
|
83
|
+
for key, raw in resp.raw_headers:
|
|
84
|
+
if key == b"set-cookie":
|
|
85
|
+
return raw.decode("latin-1")
|
|
86
|
+
raise RuntimeError("Could not build a Set-Cookie header.") # pragma: no cover - defensive
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Guard:
|
|
90
|
+
"""What `auth()` returns. One per call -- all state lives in `context.current()`."""
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------ sync reads
|
|
93
|
+
|
|
94
|
+
def user(self) -> Any:
|
|
95
|
+
ctx = context.current()
|
|
96
|
+
if ctx is None or ctx.user is context.UNRESOLVED:
|
|
97
|
+
return None
|
|
98
|
+
return ctx.user
|
|
99
|
+
|
|
100
|
+
def check(self) -> bool:
|
|
101
|
+
return self.user() is not None
|
|
102
|
+
|
|
103
|
+
def guest(self) -> bool:
|
|
104
|
+
return self.user() is None
|
|
105
|
+
|
|
106
|
+
def id(self) -> Any:
|
|
107
|
+
return getattr(self.user(), "id", None)
|
|
108
|
+
|
|
109
|
+
def via_remember(self) -> bool:
|
|
110
|
+
ctx = context.current()
|
|
111
|
+
return bool(ctx is not None and ctx.extras.get("auth.via_remember", False))
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------ async
|
|
114
|
+
|
|
115
|
+
async def attempt(self, credentials: dict[str, Any], remember: bool = False) -> bool:
|
|
116
|
+
from aso.auth.events import Failed
|
|
117
|
+
from aso.security import hash as hash_
|
|
118
|
+
|
|
119
|
+
password = str(credentials.get("password", ""))
|
|
120
|
+
lookup = {k: v for k, v in credentials.items() if k != "password"}
|
|
121
|
+
model_cls = _user_model()
|
|
122
|
+
user = await model_cls.where(**lookup).first() if lookup else None
|
|
123
|
+
stored_hash = getattr(user, "password", None) if user is not None else None
|
|
124
|
+
# Always run `hash.check` (even with a dummy hash when no user was found) so the
|
|
125
|
+
# response time does not reveal whether the email exists (T-38 "How it works").
|
|
126
|
+
valid = await asyncio.to_thread(hash_.check, password, stored_hash or _dummy_hash())
|
|
127
|
+
if user is None or not valid:
|
|
128
|
+
await Failed(user, credentials).dispatch()
|
|
129
|
+
return False
|
|
130
|
+
if await asyncio.to_thread(hash_.needs_rehash, stored_hash):
|
|
131
|
+
user.password = password # the `Hashed` column re-hashes on assignment (T-40, T-31)
|
|
132
|
+
await user.save()
|
|
133
|
+
await self.login(user, remember=remember)
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
async def validate(self, credentials: dict[str, Any]) -> bool:
|
|
137
|
+
from aso.security import hash as hash_
|
|
138
|
+
|
|
139
|
+
password = str(credentials.get("password", ""))
|
|
140
|
+
lookup = {k: v for k, v in credentials.items() if k != "password"}
|
|
141
|
+
model_cls = _user_model()
|
|
142
|
+
user = await model_cls.where(**lookup).first() if lookup else None
|
|
143
|
+
stored_hash = getattr(user, "password", None) if user is not None else None
|
|
144
|
+
valid = await asyncio.to_thread(hash_.check, password, stored_hash or _dummy_hash())
|
|
145
|
+
return user is not None and valid
|
|
146
|
+
|
|
147
|
+
async def login(self, user: Any, remember: bool = False) -> None:
|
|
148
|
+
from aso.auth.events import Login
|
|
149
|
+
from aso.session import session as get_session
|
|
150
|
+
|
|
151
|
+
s = get_session()
|
|
152
|
+
s.put(session_key(), user.id)
|
|
153
|
+
s.put(password_hash_key(), user.password)
|
|
154
|
+
ctx = context.current()
|
|
155
|
+
if ctx is not None:
|
|
156
|
+
ctx.user = user
|
|
157
|
+
if remember:
|
|
158
|
+
await self._remember(user)
|
|
159
|
+
await Login(user, remember).dispatch()
|
|
160
|
+
|
|
161
|
+
async def login_using_id(self, user_id: Any, remember: bool = False) -> Any:
|
|
162
|
+
model_cls = _user_model()
|
|
163
|
+
user = await model_cls.find(user_id)
|
|
164
|
+
if user is not None:
|
|
165
|
+
await self.login(user, remember=remember)
|
|
166
|
+
return user
|
|
167
|
+
|
|
168
|
+
async def logout(self) -> None:
|
|
169
|
+
from aso.auth.events import Logout
|
|
170
|
+
from aso.session import session as get_session
|
|
171
|
+
|
|
172
|
+
user = self.user()
|
|
173
|
+
try:
|
|
174
|
+
s = get_session()
|
|
175
|
+
s.forget(session_key(), password_hash_key())
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
ctx = context.current()
|
|
179
|
+
if ctx is not None:
|
|
180
|
+
ctx.user = None
|
|
181
|
+
ctx.extras.pop("auth.via_remember", None)
|
|
182
|
+
if user is not None:
|
|
183
|
+
# Rotate `remember_token` so "remember me" cookies elsewhere stop working too.
|
|
184
|
+
user.remember_token = secrets.token_urlsafe(45)
|
|
185
|
+
await user.save()
|
|
186
|
+
self._forget_remember_cookie()
|
|
187
|
+
if user is not None:
|
|
188
|
+
await Logout(user).dispatch()
|
|
189
|
+
|
|
190
|
+
async def logout_other_devices(self, password: str) -> bool:
|
|
191
|
+
from aso.security import hash as hash_
|
|
192
|
+
|
|
193
|
+
user = self.user()
|
|
194
|
+
if user is None:
|
|
195
|
+
return False
|
|
196
|
+
valid = await asyncio.to_thread(hash_.check, password, user.password)
|
|
197
|
+
if not valid:
|
|
198
|
+
return False
|
|
199
|
+
user.password = password # re-hash with a fresh salt: other sessions' stored hash goes stale
|
|
200
|
+
await user.save()
|
|
201
|
+
try:
|
|
202
|
+
from aso.session import session as get_session
|
|
203
|
+
|
|
204
|
+
get_session().put(password_hash_key(), user.password)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------ remember-me cookie
|
|
210
|
+
|
|
211
|
+
async def _remember(self, user: Any) -> None:
|
|
212
|
+
from aso.config import config
|
|
213
|
+
from aso.security import crypt
|
|
214
|
+
|
|
215
|
+
if not getattr(user, "remember_token", None):
|
|
216
|
+
user.remember_token = secrets.token_urlsafe(45)
|
|
217
|
+
await user.save()
|
|
218
|
+
payload = f"{user.id}|{user.remember_token}|{user.password}"
|
|
219
|
+
token = crypt.encrypt(payload)
|
|
220
|
+
minutes = int(config("auth.remember_lifetime", 60 * 24 * 400))
|
|
221
|
+
header = _build_set_cookie(remember_cookie_name(), token, max_age=minutes * 60)
|
|
222
|
+
ctx = context.current()
|
|
223
|
+
if ctx is not None:
|
|
224
|
+
ctx.extras["auth.set_remember_cookie"] = header
|
|
225
|
+
|
|
226
|
+
def _forget_remember_cookie(self) -> None:
|
|
227
|
+
ctx = context.current()
|
|
228
|
+
if ctx is not None:
|
|
229
|
+
ctx.extras["auth.forget_remember_cookie"] = True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_DUMMY_HASH: str | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _dummy_hash() -> str:
|
|
236
|
+
"""A real (but useless) hash to check against when no user was found, so a failed login by
|
|
237
|
+
an unknown email takes the same time as one by a known email (T-38 "How it works")."""
|
|
238
|
+
global _DUMMY_HASH
|
|
239
|
+
if _DUMMY_HASH is None:
|
|
240
|
+
from aso.security import hash as hash_
|
|
241
|
+
|
|
242
|
+
_DUMMY_HASH = hash_.make(secrets.token_urlsafe(32))
|
|
243
|
+
return _DUMMY_HASH
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def auth() -> _Guard:
|
|
247
|
+
return _Guard()
|
aso/auth/mail.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Built-in mailables the auth library sends itself (T-38): the password-reset link and the
|
|
2
|
+
email-verification link. Plain-text content, so they need no app-provided view template."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from aso.mail import Content, Envelope, Mailable
|
|
12
|
+
|
|
13
|
+
__all__ = ["ResetPasswordMail", "VerifyEmailMail"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResetPasswordMail(Mailable):
|
|
17
|
+
"""Sent by `password_broker().send_reset_link()`. `/reset-password/{token}?email=...`."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, email: str, token: str) -> None:
|
|
20
|
+
self.email = email
|
|
21
|
+
self.token = token
|
|
22
|
+
|
|
23
|
+
def envelope(self) -> Envelope:
|
|
24
|
+
return Envelope(subject="Reset Password Notification")
|
|
25
|
+
|
|
26
|
+
def content(self) -> Content:
|
|
27
|
+
return Content(text=f"You are receiving this email because we received a password reset "
|
|
28
|
+
f"request for your account.\n\nReset your password: {self._url()}\n\n"
|
|
29
|
+
f"If you did not request a password reset, no further action is required.")
|
|
30
|
+
|
|
31
|
+
def _url(self) -> str:
|
|
32
|
+
from aso.routing import route, url
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
path = route("password.reset", {"token": self.token})
|
|
36
|
+
except (KeyError, RuntimeError):
|
|
37
|
+
path = f"/reset-password/{self.token}"
|
|
38
|
+
return f"{url(path)}?email={quote(self.email)}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VerifyEmailMail(Mailable):
|
|
42
|
+
"""Sent by `MustVerifyEmail.send_email_verification_notification()`. A temporary signed URL,
|
|
43
|
+
`/email/verify/{id}/{hash}?expires=...&signature=...` (T-21), valid for 60 minutes."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, user: Any) -> None:
|
|
46
|
+
self.user = user
|
|
47
|
+
|
|
48
|
+
def envelope(self) -> Envelope:
|
|
49
|
+
return Envelope(subject="Verify Email Address")
|
|
50
|
+
|
|
51
|
+
def content(self) -> Content:
|
|
52
|
+
return Content(text=f"Please click the link below to verify your email address.\n\n{self._url()}\n\n"
|
|
53
|
+
f"If you did not create an account, no further action is required.")
|
|
54
|
+
|
|
55
|
+
def _url(self) -> str:
|
|
56
|
+
from aso.routing import url
|
|
57
|
+
|
|
58
|
+
email_hash = hashlib.sha1(str(self.user.email).lower().encode()).hexdigest()
|
|
59
|
+
return url().temporary_signed_route(
|
|
60
|
+
"verification.verify", timedelta(minutes=60), {"id": self.user.id, "hash": email_hash}
|
|
61
|
+
)
|