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.
Files changed (358) hide show
  1. aso/__init__.py +3 -0
  2. aso/auth/__init__.py +33 -0
  3. aso/auth/authenticatable.py +49 -0
  4. aso/auth/events.py +68 -0
  5. aso/auth/features.py +15 -0
  6. aso/auth/gate.py +187 -0
  7. aso/auth/guard.py +247 -0
  8. aso/auth/mail.py +61 -0
  9. aso/auth/middleware.py +281 -0
  10. aso/auth/password_broker.py +121 -0
  11. aso/cache/__init__.py +18 -0
  12. aso/cache/file.py +162 -0
  13. aso/cache/manager.py +169 -0
  14. aso/cache/memory.py +57 -0
  15. aso/cache/redis_store.py +55 -0
  16. aso/config/__init__.py +130 -0
  17. aso/console/__init__.py +25 -0
  18. aso/console/boot.py +88 -0
  19. aso/console/cli.py +207 -0
  20. aso/console/command.py +79 -0
  21. aso/console/commands/__init__.py +35 -0
  22. aso/console/commands/core.py +312 -0
  23. aso/console/commands/db.py +169 -0
  24. aso/console/commands/doctor.py +243 -0
  25. aso/console/commands/key.py +51 -0
  26. aso/console/commands/make.py +843 -0
  27. aso/console/commands/migrate.py +138 -0
  28. aso/console/commands/new.py +232 -0
  29. aso/console/commands/queue.py +110 -0
  30. aso/console/commands/route.py +44 -0
  31. aso/console/commands/routes_generate.py +80 -0
  32. aso/console/commands/schedule.py +92 -0
  33. aso/console/commands/seed.py +30 -0
  34. aso/console/commands/session.py +22 -0
  35. aso/console/commands/storage.py +22 -0
  36. aso/console/commands/test.py +62 -0
  37. aso/console/io.py +83 -0
  38. aso/console/migrations.py +399 -0
  39. aso/console/registry.py +231 -0
  40. aso/console/schedule.py +173 -0
  41. aso/console/signature.py +114 -0
  42. aso/console/stubs/agents_md.stub +1 -0
  43. aso/console/stubs/command.stub +1 -0
  44. aso/console/stubs/controller.stub +1 -0
  45. aso/console/stubs/event.stub +1 -0
  46. aso/console/stubs/factory.stub +1 -0
  47. aso/console/stubs/job.stub +1 -0
  48. aso/console/stubs/listener.stub +1 -0
  49. aso/console/stubs/mail.stub +1 -0
  50. aso/console/stubs/mail_view.stub +1 -0
  51. aso/console/stubs/middleware.stub +1 -0
  52. aso/console/stubs/model.stub +1 -0
  53. aso/console/stubs/page.stub +1 -0
  54. aso/console/stubs/policy.stub +1 -0
  55. aso/console/stubs/request.stub +1 -0
  56. aso/console/stubs/seeder.stub +1 -0
  57. aso/console/stubs/test.stub +1 -0
  58. aso/console/support.py +18 -0
  59. aso/context.py +67 -0
  60. aso/database/__init__.py +45 -0
  61. aso/database/builder.py +678 -0
  62. aso/database/db.py +110 -0
  63. aso/database/engine.py +217 -0
  64. aso/database/factory.py +196 -0
  65. aso/database/middleware.py +19 -0
  66. aso/database/model.py +560 -0
  67. aso/database/pagination.py +326 -0
  68. aso/database/relationships.py +165 -0
  69. aso/database/scope.py +174 -0
  70. aso/database/seeder.py +67 -0
  71. aso/database/types.py +160 -0
  72. aso/events/__init__.py +24 -0
  73. aso/events/event.py +128 -0
  74. aso/events/listener_job.py +50 -0
  75. aso/exceptions/__init__.py +45 -0
  76. aso/exceptions/debug.py +104 -0
  77. aso/exceptions/handler.py +366 -0
  78. aso/exceptions/types.py +210 -0
  79. aso/foundation/__init__.py +37 -0
  80. aso/foundation/application.py +227 -0
  81. aso/foundation/exceptions_config.py +49 -0
  82. aso/foundation/middleware_config.py +141 -0
  83. aso/foundation/paths.py +49 -0
  84. aso/foundation/stack.py +165 -0
  85. aso/http/__init__.py +36 -0
  86. aso/http/controller.py +35 -0
  87. aso/http/encoding.py +58 -0
  88. aso/http/method_spoofing.py +72 -0
  89. aso/http/middleware.py +149 -0
  90. aso/http/request.py +284 -0
  91. aso/http/responses.py +203 -0
  92. aso/inertia/__init__.py +28 -0
  93. aso/inertia/history.py +22 -0
  94. aso/inertia/json_encode.py +45 -0
  95. aso/inertia/middleware.py +108 -0
  96. aso/inertia/property.py +23 -0
  97. aso/inertia/props.py +150 -0
  98. aso/inertia/response.py +390 -0
  99. aso/inertia/root.py +40 -0
  100. aso/inertia/shared.py +18 -0
  101. aso/inertia/vite.py +150 -0
  102. aso/log/__init__.py +10 -0
  103. aso/log/manager.py +246 -0
  104. aso/log/testing.py +59 -0
  105. aso/mail/__init__.py +39 -0
  106. aso/mail/drivers.py +138 -0
  107. aso/mail/mailable.py +98 -0
  108. aso/mail/manager.py +215 -0
  109. aso/mail/queued_mail.py +24 -0
  110. aso/mail/rendering.py +101 -0
  111. aso/mail/resources/views/mail/layout.html +20 -0
  112. aso/queue/__init__.py +39 -0
  113. aso/queue/drivers.py +98 -0
  114. aso/queue/failed.py +100 -0
  115. aso/queue/job.py +90 -0
  116. aso/queue/manager.py +257 -0
  117. aso/queue/serialization.py +76 -0
  118. aso/queue/worker.py +117 -0
  119. aso/rate_limiting/__init__.py +20 -0
  120. aso/rate_limiting/limit.py +40 -0
  121. aso/rate_limiting/limiter.py +79 -0
  122. aso/rate_limiting/middleware.py +73 -0
  123. aso/routing/__init__.py +16 -0
  124. aso/routing/paths.py +118 -0
  125. aso/routing/pipeline.py +204 -0
  126. aso/routing/registry.py +701 -0
  127. aso/routing/signed.py +95 -0
  128. aso/routing/static.py +42 -0
  129. aso/routing/ts_routes.py +482 -0
  130. aso/routing/url.py +65 -0
  131. aso/security/__init__.py +9 -0
  132. aso/security/crypt.py +57 -0
  133. aso/security/hash.py +55 -0
  134. aso/security/keys.py +66 -0
  135. aso/session/__init__.py +40 -0
  136. aso/session/configuration.py +54 -0
  137. aso/session/drivers.py +239 -0
  138. aso/session/forgery.py +157 -0
  139. aso/session/middleware.py +161 -0
  140. aso/session/store.py +255 -0
  141. aso/skeleton/.env.example +26 -0
  142. aso/skeleton/.env.testing +20 -0
  143. aso/skeleton/.gitignore +24 -0
  144. aso/skeleton/AGENTS.md +49 -0
  145. aso/skeleton/LICENSES/laravel-react-starter-kit.md +35 -0
  146. aso/skeleton/app/__init__.py +0 -0
  147. aso/skeleton/app/actions/__init__.py +0 -0
  148. aso/skeleton/app/actions/auth/__init__.py +0 -0
  149. aso/skeleton/app/actions/auth/create_new_user.py +16 -0
  150. aso/skeleton/app/actions/auth/password_validation_rules.py +28 -0
  151. aso/skeleton/app/actions/auth/reset_user_password.py +15 -0
  152. aso/skeleton/app/console/__init__.py +0 -0
  153. aso/skeleton/app/console/commands/__init__.py +0 -0
  154. aso/skeleton/app/exceptions/__init__.py +0 -0
  155. aso/skeleton/app/http/__init__.py +0 -0
  156. aso/skeleton/app/http/controllers/__init__.py +0 -0
  157. aso/skeleton/app/http/controllers/auth/__init__.py +0 -0
  158. aso/skeleton/app/http/controllers/auth/authenticated_session_controller.py +32 -0
  159. aso/skeleton/app/http/controllers/auth/confirmable_password_controller.py +24 -0
  160. aso/skeleton/app/http/controllers/auth/email_verification_notification_controller.py +15 -0
  161. aso/skeleton/app/http/controllers/auth/email_verification_prompt_controller.py +15 -0
  162. aso/skeleton/app/http/controllers/auth/new_password_controller.py +27 -0
  163. aso/skeleton/app/http/controllers/auth/password_reset_link_controller.py +20 -0
  164. aso/skeleton/app/http/controllers/auth/registered_user_controller.py +24 -0
  165. aso/skeleton/app/http/controllers/auth/verify_email_controller.py +23 -0
  166. aso/skeleton/app/http/controllers/dashboard_controller.py +9 -0
  167. aso/skeleton/app/http/controllers/settings/__init__.py +0 -0
  168. aso/skeleton/app/http/controllers/settings/profile_controller.py +39 -0
  169. aso/skeleton/app/http/controllers/settings/security_controller.py +20 -0
  170. aso/skeleton/app/http/middleware/__init__.py +0 -0
  171. aso/skeleton/app/http/middleware/handle_appearance.py +25 -0
  172. aso/skeleton/app/http/middleware/handle_inertia_requests.py +17 -0
  173. aso/skeleton/app/http/requests/__init__.py +0 -0
  174. aso/skeleton/app/http/requests/auth/__init__.py +0 -0
  175. aso/skeleton/app/http/requests/auth/confirm_password_request.py +7 -0
  176. aso/skeleton/app/http/requests/auth/forgot_password_request.py +9 -0
  177. aso/skeleton/app/http/requests/auth/login_request.py +41 -0
  178. aso/skeleton/app/http/requests/auth/register_request.py +16 -0
  179. aso/skeleton/app/http/requests/auth/reset_password_request.py +16 -0
  180. aso/skeleton/app/http/requests/settings/__init__.py +0 -0
  181. aso/skeleton/app/http/requests/settings/delete_user_request.py +10 -0
  182. aso/skeleton/app/http/requests/settings/password_update_request.py +13 -0
  183. aso/skeleton/app/http/requests/settings/profile_update_request.py +13 -0
  184. aso/skeleton/app/jobs/__init__.py +0 -0
  185. aso/skeleton/app/listeners/__init__.py +0 -0
  186. aso/skeleton/app/listeners/send_email_verification_notification.py +14 -0
  187. aso/skeleton/app/mail/__init__.py +0 -0
  188. aso/skeleton/app/models/__init__.py +0 -0
  189. aso/skeleton/app/models/user.py +19 -0
  190. aso/skeleton/app/policies/__init__.py +0 -0
  191. aso/skeleton/bootstrap/app.py +30 -0
  192. aso/skeleton/components.json +21 -0
  193. aso/skeleton/config/app.py +12 -0
  194. aso/skeleton/config/auth.py +50 -0
  195. aso/skeleton/config/cache.py +14 -0
  196. aso/skeleton/config/database.py +34 -0
  197. aso/skeleton/config/filesystems.py +18 -0
  198. aso/skeleton/config/hashing.py +9 -0
  199. aso/skeleton/config/inertia.py +10 -0
  200. aso/skeleton/config/logging.py +18 -0
  201. aso/skeleton/config/mail.py +28 -0
  202. aso/skeleton/config/queue.py +14 -0
  203. aso/skeleton/config/session.py +19 -0
  204. aso/skeleton/database/__init__.py +0 -0
  205. aso/skeleton/database/factories/__init__.py +0 -0
  206. aso/skeleton/database/factories/user_factory.py +22 -0
  207. aso/skeleton/database/migrations/2026_01_01_000000_create_users_table.py +62 -0
  208. aso/skeleton/database/migrations/__init__.py +0 -0
  209. aso/skeleton/database/seeders/__init__.py +0 -0
  210. aso/skeleton/database/seeders/database_seeder.py +8 -0
  211. aso/skeleton/package-lock.json +2625 -0
  212. aso/skeleton/package.json +42 -0
  213. aso/skeleton/public/.gitkeep +0 -0
  214. aso/skeleton/public/favicon.svg +12 -0
  215. aso/skeleton/pyproject.toml +45 -0
  216. aso/skeleton/resources/css/app.css +138 -0
  217. aso/skeleton/resources/js/actions/app/http/controllers/auth/authenticated_session_controller.ts +54 -0
  218. aso/skeleton/resources/js/actions/app/http/controllers/auth/confirmable_password_controller.ts +39 -0
  219. aso/skeleton/resources/js/actions/app/http/controllers/auth/email_verification_notification_controller.ts +23 -0
  220. aso/skeleton/resources/js/actions/app/http/controllers/auth/email_verification_prompt_controller.ts +22 -0
  221. aso/skeleton/resources/js/actions/app/http/controllers/auth/new_password_controller.ts +42 -0
  222. aso/skeleton/resources/js/actions/app/http/controllers/auth/password_reset_link_controller.ts +39 -0
  223. aso/skeleton/resources/js/actions/app/http/controllers/auth/registered_user_controller.ts +39 -0
  224. aso/skeleton/resources/js/actions/app/http/controllers/auth/verify_email_controller.ts +25 -0
  225. aso/skeleton/resources/js/actions/app/http/controllers/dashboard_controller.ts +24 -0
  226. aso/skeleton/resources/js/actions/app/http/controllers/settings/profile_controller.ts +54 -0
  227. aso/skeleton/resources/js/actions/app/http/controllers/settings/security_controller.ts +39 -0
  228. aso/skeleton/resources/js/app.tsx +39 -0
  229. aso/skeleton/resources/js/components/app-content.tsx +23 -0
  230. aso/skeleton/resources/js/components/app-header.tsx +249 -0
  231. aso/skeleton/resources/js/components/app-logo-icon.tsx +16 -0
  232. aso/skeleton/resources/js/components/app-logo.tsx +21 -0
  233. aso/skeleton/resources/js/components/app-shell.tsx +22 -0
  234. aso/skeleton/resources/js/components/app-sidebar-header.tsx +19 -0
  235. aso/skeleton/resources/js/components/app-sidebar.tsx +66 -0
  236. aso/skeleton/resources/js/components/appearance-tabs.tsx +47 -0
  237. aso/skeleton/resources/js/components/breadcrumbs.tsx +51 -0
  238. aso/skeleton/resources/js/components/delete-user.tsx +122 -0
  239. aso/skeleton/resources/js/components/heading.tsx +27 -0
  240. aso/skeleton/resources/js/components/input-error.tsx +18 -0
  241. aso/skeleton/resources/js/components/nav-footer.tsx +50 -0
  242. aso/skeleton/resources/js/components/nav-main.tsx +37 -0
  243. aso/skeleton/resources/js/components/nav-user.tsx +59 -0
  244. aso/skeleton/resources/js/components/password-input.tsx +38 -0
  245. aso/skeleton/resources/js/components/text-link.tsx +24 -0
  246. aso/skeleton/resources/js/components/ui/alert.tsx +67 -0
  247. aso/skeleton/resources/js/components/ui/avatar.tsx +52 -0
  248. aso/skeleton/resources/js/components/ui/breadcrumb.tsx +110 -0
  249. aso/skeleton/resources/js/components/ui/button.tsx +59 -0
  250. aso/skeleton/resources/js/components/ui/card.tsx +69 -0
  251. aso/skeleton/resources/js/components/ui/checkbox.tsx +31 -0
  252. aso/skeleton/resources/js/components/ui/dialog.tsx +134 -0
  253. aso/skeleton/resources/js/components/ui/dropdown-menu.tsx +256 -0
  254. aso/skeleton/resources/js/components/ui/icon.tsx +15 -0
  255. aso/skeleton/resources/js/components/ui/input.tsx +22 -0
  256. aso/skeleton/resources/js/components/ui/label.tsx +23 -0
  257. aso/skeleton/resources/js/components/ui/navigation-menu.tsx +169 -0
  258. aso/skeleton/resources/js/components/ui/placeholder-pattern.tsx +21 -0
  259. aso/skeleton/resources/js/components/ui/separator.tsx +27 -0
  260. aso/skeleton/resources/js/components/ui/sheet.tsx +138 -0
  261. aso/skeleton/resources/js/components/ui/sidebar.tsx +720 -0
  262. aso/skeleton/resources/js/components/ui/skeleton.tsx +14 -0
  263. aso/skeleton/resources/js/components/ui/sonner.tsx +28 -0
  264. aso/skeleton/resources/js/components/ui/spinner.tsx +17 -0
  265. aso/skeleton/resources/js/components/ui/tooltip.tsx +56 -0
  266. aso/skeleton/resources/js/components/user-info.tsx +33 -0
  267. aso/skeleton/resources/js/components/user-menu-content.tsx +65 -0
  268. aso/skeleton/resources/js/hooks/use-appearance.tsx +116 -0
  269. aso/skeleton/resources/js/hooks/use-current-url.ts +84 -0
  270. aso/skeleton/resources/js/hooks/use-flash-toast.ts +30 -0
  271. aso/skeleton/resources/js/hooks/use-initials.tsx +27 -0
  272. aso/skeleton/resources/js/hooks/use-mobile-navigation.ts +11 -0
  273. aso/skeleton/resources/js/hooks/use-mobile.tsx +37 -0
  274. aso/skeleton/resources/js/layouts/app/app-header-layout.tsx +17 -0
  275. aso/skeleton/resources/js/layouts/app/app-sidebar-layout.tsx +21 -0
  276. aso/skeleton/resources/js/layouts/app-layout.tsx +17 -0
  277. aso/skeleton/resources/js/layouts/auth/auth-card-layout.tsx +49 -0
  278. aso/skeleton/resources/js/layouts/auth/auth-simple-layout.tsx +39 -0
  279. aso/skeleton/resources/js/layouts/auth/auth-split-layout.tsx +45 -0
  280. aso/skeleton/resources/js/layouts/auth-layout.tsx +18 -0
  281. aso/skeleton/resources/js/layouts/settings/layout.tsx +79 -0
  282. aso/skeleton/resources/js/lib/utils.ts +13 -0
  283. aso/skeleton/resources/js/pages/auth/confirm-password.tsx +54 -0
  284. aso/skeleton/resources/js/pages/auth/forgot-password.tsx +70 -0
  285. aso/skeleton/resources/js/pages/auth/login.tsx +116 -0
  286. aso/skeleton/resources/js/pages/auth/register.tsx +122 -0
  287. aso/skeleton/resources/js/pages/auth/reset-password.tsx +98 -0
  288. aso/skeleton/resources/js/pages/auth/verify-email.tsx +47 -0
  289. aso/skeleton/resources/js/pages/dashboard.tsx +38 -0
  290. aso/skeleton/resources/js/pages/error.tsx +46 -0
  291. aso/skeleton/resources/js/pages/settings/appearance.tsx +34 -0
  292. aso/skeleton/resources/js/pages/settings/profile.tsx +134 -0
  293. aso/skeleton/resources/js/pages/settings/security.tsx +135 -0
  294. aso/skeleton/resources/js/pages/welcome.tsx +96 -0
  295. aso/skeleton/resources/js/routes/appearance/index.ts +20 -0
  296. aso/skeleton/resources/js/routes/index.ts +79 -0
  297. aso/skeleton/resources/js/routes/login/index.ts +19 -0
  298. aso/skeleton/resources/js/routes/password/confirm/index.ts +19 -0
  299. aso/skeleton/resources/js/routes/password/index.ts +81 -0
  300. aso/skeleton/resources/js/routes/profile/index.ts +48 -0
  301. aso/skeleton/resources/js/routes/register/index.ts +19 -0
  302. aso/skeleton/resources/js/routes/security/index.ts +20 -0
  303. aso/skeleton/resources/js/routes/user-password/index.ts +19 -0
  304. aso/skeleton/resources/js/routes/verification/index.ts +52 -0
  305. aso/skeleton/resources/js/types/auth.ts +16 -0
  306. aso/skeleton/resources/js/types/global.d.ts +32 -0
  307. aso/skeleton/resources/js/types/index.ts +5 -0
  308. aso/skeleton/resources/js/types/navigation.ts +16 -0
  309. aso/skeleton/resources/js/types/ui.ts +23 -0
  310. aso/skeleton/resources/js/types/vite-env.d.ts +1 -0
  311. aso/skeleton/resources/js/wayfinder/index.ts +150 -0
  312. aso/skeleton/resources/views/app.html +27 -0
  313. aso/skeleton/resources/views/mail/layout.html +20 -0
  314. aso/skeleton/routes/api.py +7 -0
  315. aso/skeleton/routes/auth.py +56 -0
  316. aso/skeleton/routes/console.py +1 -0
  317. aso/skeleton/routes/settings.py +27 -0
  318. aso/skeleton/routes/web.py +15 -0
  319. aso/skeleton/storage/app/public/.gitkeep +0 -0
  320. aso/skeleton/storage/framework/cache/.gitkeep +0 -0
  321. aso/skeleton/storage/logs/.gitkeep +0 -0
  322. aso/skeleton/tests/architecture/test_architecture.py +5 -0
  323. aso/skeleton/tests/conftest.py +16 -0
  324. aso/skeleton/tests/feature/auth/test_authentication.py +45 -0
  325. aso/skeleton/tests/feature/auth/test_email_verification.py +48 -0
  326. aso/skeleton/tests/feature/auth/test_password_confirmation.py +20 -0
  327. aso/skeleton/tests/feature/auth/test_password_reset.py +56 -0
  328. aso/skeleton/tests/feature/auth/test_registration.py +38 -0
  329. aso/skeleton/tests/feature/settings/test_password_update.py +38 -0
  330. aso/skeleton/tests/feature/settings/test_profile_update.py +61 -0
  331. aso/skeleton/tsconfig.json +20 -0
  332. aso/skeleton/vite/aso.ts +132 -0
  333. aso/skeleton/vite.config.ts +29 -0
  334. aso/storage/__init__.py +19 -0
  335. aso/storage/manager.py +167 -0
  336. aso/support/__init__.py +7 -0
  337. aso/support/str_helpers.py +30 -0
  338. aso/testing/__init__.py +31 -0
  339. aso/testing/arch/__init__.py +12 -0
  340. aso/testing/arch/_ast_utils.py +223 -0
  341. aso/testing/arch/_preset.py +411 -0
  342. aso/testing/arch/_selection.py +429 -0
  343. aso/testing/client.py +305 -0
  344. aso/testing/database.py +90 -0
  345. aso/testing/inertia_assert.py +235 -0
  346. aso/testing/options.py +27 -0
  347. aso/testing/plugin.py +164 -0
  348. aso/testing/response.py +358 -0
  349. aso/validation/__init__.py +33 -0
  350. aso/validation/form_request.py +259 -0
  351. aso/validation/input.py +100 -0
  352. aso/validation/messages.py +218 -0
  353. aso/validation/rules.py +247 -0
  354. aso/validation/uploaded_file.py +82 -0
  355. aso-0.2.0.dist-info/METADATA +100 -0
  356. aso-0.2.0.dist-info/RECORD +358 -0
  357. aso-0.2.0.dist-info/WHEEL +4 -0
  358. aso-0.2.0.dist-info/entry_points.txt +5 -0
aso/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Aso: a Laravel-style web framework for Python (FastAPI + Inertia.js)."""
2
+
3
+ __version__ = "0.1.0.dev0"
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
+ )