fastapi-forge-cli 0.1.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 (181) hide show
  1. fastapi_forge/__init__.py +7 -0
  2. fastapi_forge/__main__.py +6 -0
  3. fastapi_forge/cli.py +211 -0
  4. fastapi_forge/templates/with_rbac/Dockerfile +31 -0
  5. fastapi_forge/templates/with_rbac/README.md +121 -0
  6. fastapi_forge/templates/with_rbac/_dockerignore +16 -0
  7. fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
  8. fastapi_forge/templates/with_rbac/_gitignore +19 -0
  9. fastapi_forge/templates/with_rbac/alembic/README +1 -0
  10. fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
  11. fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
  12. fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
  13. fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
  14. fastapi_forge/templates/with_rbac/alembic.ini +35 -0
  15. fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
  16. fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
  17. fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
  18. fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
  19. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  20. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
  21. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
  22. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
  23. fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
  24. fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
  25. fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
  26. fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
  27. fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
  28. fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
  29. fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
  30. fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
  31. fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
  32. fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
  33. fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
  34. fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
  35. fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
  36. fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
  37. fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
  38. fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
  39. fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
  40. fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
  41. fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
  42. fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
  43. fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
  44. fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
  45. fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
  46. fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
  47. fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
  48. fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
  49. fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
  50. fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
  51. fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
  52. fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
  53. fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
  54. fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
  55. fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
  56. fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
  57. fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
  58. fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
  59. fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
  60. fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
  61. fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
  62. fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
  63. fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
  64. fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
  65. fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
  66. fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
  67. fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
  68. fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
  69. fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
  70. fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
  71. fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
  72. fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
  73. fastapi_forge/templates/with_rbac/app/main.py +66 -0
  74. fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
  75. fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
  76. fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
  77. fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
  78. fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
  79. fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
  80. fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
  81. fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
  82. fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
  83. fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
  84. fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
  85. fastapi_forge/templates/with_rbac/compose.yaml +33 -0
  86. fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
  87. fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
  88. fastapi_forge/templates/with_rbac/requirements.txt +16 -0
  89. fastapi_forge/templates/with_rbac/sample.env +42 -0
  90. fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
  91. fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
  92. fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
  93. fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
  94. fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
  95. fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
  96. fastapi_forge/templates/without_rbac/Dockerfile +31 -0
  97. fastapi_forge/templates/without_rbac/README.md +106 -0
  98. fastapi_forge/templates/without_rbac/_dockerignore +16 -0
  99. fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
  100. fastapi_forge/templates/without_rbac/_gitignore +19 -0
  101. fastapi_forge/templates/without_rbac/alembic/README +1 -0
  102. fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
  103. fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
  104. fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
  105. fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
  106. fastapi_forge/templates/without_rbac/alembic.ini +35 -0
  107. fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
  108. fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
  109. fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
  110. fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
  111. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  112. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
  113. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
  114. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
  115. fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
  116. fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
  117. fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
  118. fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
  119. fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
  120. fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
  121. fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
  122. fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
  123. fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
  124. fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
  125. fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
  126. fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
  127. fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
  128. fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
  129. fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
  130. fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
  131. fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
  132. fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
  133. fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
  134. fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
  135. fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
  136. fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
  137. fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
  138. fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
  139. fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
  140. fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
  141. fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
  142. fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
  143. fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
  144. fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
  145. fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
  146. fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
  147. fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
  148. fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
  149. fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
  150. fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
  151. fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
  152. fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
  153. fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
  154. fastapi_forge/templates/without_rbac/app/main.py +66 -0
  155. fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
  156. fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
  157. fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
  158. fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
  159. fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
  160. fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
  161. fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
  162. fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
  163. fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
  164. fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
  165. fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
  166. fastapi_forge/templates/without_rbac/compose.yaml +33 -0
  167. fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
  168. fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
  169. fastapi_forge/templates/without_rbac/requirements.txt +16 -0
  170. fastapi_forge/templates/without_rbac/sample.env +42 -0
  171. fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
  172. fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
  173. fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
  174. fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
  175. fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
  176. fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
  177. fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
  178. fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
  179. fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  180. fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
  181. fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ from typing import Optional
2
+
3
+ from fastapi import APIRouter, Depends, Request, Response
4
+ from fastapi.security import HTTPAuthorizationCredentials
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.api.v1.auth.repository import UserRepository
8
+ from app.api.v1.auth.schema import (
9
+ ChangePasswordRequest,
10
+ LoginRequest,
11
+ PasswordResetConfirmRequest,
12
+ PasswordResetRequest,
13
+ ResendVerificationRequest,
14
+ UserResponse,
15
+ )
16
+ from app.api.v1.auth.service import AuthService
17
+ from app.core.config import settings
18
+ from app.core.dependencies import bearer_scheme, get_async_db, get_current_user_id
19
+ from app.core.exceptions import UnauthorizedException
20
+ from app.core.middleware import limiter
21
+ from app.core.responses import success_response
22
+
23
+ router = APIRouter()
24
+
25
+ REFRESH_TOKEN_COOKIE_NAME = "refresh_token"
26
+ REFRESH_TOKEN_COOKIE_PATH = "/api/v1/auth"
27
+
28
+
29
+ def _set_refresh_token_cookie(response: Response, refresh_token: str) -> None:
30
+ response.set_cookie(
31
+ key=REFRESH_TOKEN_COOKIE_NAME,
32
+ value=refresh_token,
33
+ max_age=settings.refresh_token_expire_days * 24 * 60 * 60,
34
+ httponly=True,
35
+ secure=settings.is_production,
36
+ samesite="lax",
37
+ path=REFRESH_TOKEN_COOKIE_PATH,
38
+ )
39
+
40
+
41
+ def _clear_refresh_token_cookie(response: Response) -> None:
42
+ response.delete_cookie(
43
+ key=REFRESH_TOKEN_COOKIE_NAME,
44
+ path=REFRESH_TOKEN_COOKIE_PATH,
45
+ httponly=True,
46
+ secure=settings.is_production,
47
+ samesite="lax",
48
+ )
49
+
50
+
51
+ def _refresh_token_from_cookie(request: Request) -> str:
52
+ refresh_token = request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)
53
+ if not refresh_token:
54
+ raise UnauthorizedException("Refresh token missing")
55
+ return refresh_token
56
+
57
+
58
+ def _request_meta(request: Request) -> dict:
59
+ return {
60
+ "ip_address": request.client.host if request.client else None,
61
+ "user_agent": request.headers.get("user-agent"),
62
+ "request_id": getattr(request.state, "request_id", None),
63
+ }
64
+
65
+
66
+ @router.post("/login", summary="Obtain access and refresh tokens")
67
+ @limiter.limit("10/minute")
68
+ async def login(
69
+ request: Request,
70
+ response: Response,
71
+ payload: LoginRequest,
72
+ db: AsyncSession = Depends(get_async_db),
73
+ ):
74
+ tokens = await AuthService(db).login(payload, _request_meta(request))
75
+ _set_refresh_token_cookie(response, tokens.refresh_token)
76
+ return success_response(data=tokens.model_dump(), message="Login successful")
77
+
78
+
79
+ @router.post("/refresh", summary="Refresh access token")
80
+ async def refresh_token(
81
+ request: Request,
82
+ response: Response,
83
+ db: AsyncSession = Depends(get_async_db),
84
+ ):
85
+ tokens = await AuthService(db).refresh(
86
+ _refresh_token_from_cookie(request), _request_meta(request)
87
+ )
88
+ _set_refresh_token_cookie(response, tokens["refresh_token"])
89
+ return success_response(
90
+ data={key: value for key, value in tokens.items() if key != "refresh_token"},
91
+ message="Token refreshed",
92
+ )
93
+
94
+
95
+ @router.post("/logout", summary="Logout and revoke tokens")
96
+ async def logout(
97
+ request: Request,
98
+ response: Response,
99
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
100
+ db: AsyncSession = Depends(get_async_db),
101
+ ):
102
+ await AuthService(db).logout(
103
+ access_token=credentials.credentials if credentials else None,
104
+ refresh_token=_refresh_token_from_cookie(request),
105
+ request_meta=_request_meta(request),
106
+ )
107
+ _clear_refresh_token_cookie(response)
108
+ return success_response(message="Logout successful")
109
+
110
+
111
+ @router.post("/verify-email/resend", summary="Send an email verification link")
112
+ @limiter.limit("3/minute")
113
+ async def resend_verification(
114
+ request: Request,
115
+ payload: ResendVerificationRequest,
116
+ db: AsyncSession = Depends(get_async_db),
117
+ ):
118
+ await AuthService(db).send_verification_email(payload.email)
119
+ return success_response(message="If the account exists, a link was sent")
120
+
121
+
122
+ @router.get("/verify-email", summary="Verify an email address")
123
+ async def verify_email(
124
+ request: Request,
125
+ token: str,
126
+ db: AsyncSession = Depends(get_async_db),
127
+ ):
128
+ await AuthService(db).verify_email(token, _request_meta(request))
129
+ return success_response(message="Email verified")
130
+
131
+
132
+ @router.post("/password-reset", summary="Request a password reset link")
133
+ @limiter.limit("5/minute")
134
+ async def request_password_reset(
135
+ request: Request,
136
+ payload: PasswordResetRequest,
137
+ db: AsyncSession = Depends(get_async_db),
138
+ ):
139
+ await AuthService(db).request_password_reset(payload)
140
+ return success_response(message="If the account exists, a link was sent")
141
+
142
+
143
+ @router.post("/password-reset/confirm", summary="Reset password with a token")
144
+ @limiter.limit("5/minute")
145
+ async def confirm_password_reset(
146
+ request: Request,
147
+ payload: PasswordResetConfirmRequest,
148
+ db: AsyncSession = Depends(get_async_db),
149
+ ):
150
+ await AuthService(db).confirm_password_reset(payload, _request_meta(request))
151
+ return success_response(message="Password reset successful")
152
+
153
+
154
+ @router.post("/change-password", summary="Change current user's password")
155
+ async def change_password(
156
+ request: Request,
157
+ payload: ChangePasswordRequest,
158
+ user_id: str = Depends(get_current_user_id),
159
+ db: AsyncSession = Depends(get_async_db),
160
+ ):
161
+ await AuthService(db).change_password(user_id, payload, _request_meta(request))
162
+ return success_response(message="Password changed")
163
+
164
+
165
+ @router.get("/sessions", summary="List current user's active sessions")
166
+ async def list_sessions(
167
+ user_id: str = Depends(get_current_user_id),
168
+ db: AsyncSession = Depends(get_async_db),
169
+ ):
170
+ sessions = await AuthService(db).list_sessions(user_id)
171
+ return success_response(data=[item.model_dump() for item in sessions])
172
+
173
+
174
+ @router.delete("/sessions/{session_id}", summary="Revoke one active session")
175
+ async def revoke_session(
176
+ session_id: str,
177
+ request: Request,
178
+ user_id: str = Depends(get_current_user_id),
179
+ db: AsyncSession = Depends(get_async_db),
180
+ ):
181
+ await AuthService(db).revoke_session(
182
+ user_id, session_id, _request_meta(request)
183
+ )
184
+ return success_response(message="Session revoked")
185
+
186
+
187
+ @router.get("/me", summary="Get current authenticated user")
188
+ async def me(
189
+ user_id: str = Depends(get_current_user_id),
190
+ db: AsyncSession = Depends(get_async_db),
191
+ ):
192
+ repo = UserRepository(db)
193
+ user = await repo.get_with_permissions(user_id)
194
+ if not user or not user.is_active:
195
+ raise UnauthorizedException("User not found or deactivated")
196
+
197
+ response = UserResponse(
198
+ id=str(user.id),
199
+ email=user.email,
200
+ username=user.username,
201
+ full_name=user.full_name,
202
+ is_active=user.is_active,
203
+ is_superuser=user.is_superuser,
204
+ is_verified=user.is_verified,
205
+ last_login_at=user.last_login_at.isoformat() if user.last_login_at else None,
206
+ role=repo.collect_role_name(user),
207
+ permissions=repo.collect_permissions(user),
208
+ )
209
+ return success_response(data=response.model_dump())
@@ -0,0 +1,98 @@
1
+ from typing import List, Optional
2
+ from pydantic import EmailStr, Field, field_validator
3
+
4
+ from app.utils.casing import CamelModel
5
+ import re
6
+
7
+
8
+ # ── Request schemas ──────────────────────────────────────────────
9
+ class RegisterRequest(CamelModel):
10
+ email: EmailStr
11
+ username: str = Field(..., min_length=3, max_length=50)
12
+ full_name: str = Field(..., min_length=1, max_length=255)
13
+ password: str = Field(..., min_length=8, max_length=128)
14
+
15
+ @field_validator("password")
16
+ @classmethod
17
+ def validate_password_strength(cls, v: str) -> str:
18
+ errors = []
19
+ if not re.search(r"[A-Z]", v):
20
+ errors.append("at least one uppercase letter")
21
+ if not re.search(r"[a-z]", v):
22
+ errors.append("at least one lowercase letter")
23
+ if not re.search(r"\d", v):
24
+ errors.append("at least one digit")
25
+ if not re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?]", v):
26
+ errors.append("at least one special character")
27
+ if errors:
28
+ raise ValueError(f"Password must contain {', '.join(errors)}")
29
+ return v
30
+
31
+ @field_validator("username")
32
+ @classmethod
33
+ def validate_username(cls, v: str) -> str:
34
+ if not re.match(r"^[a-zA-Z0-9_]+$", v):
35
+ raise ValueError(
36
+ "Username may only contain letters, digits, and underscores"
37
+ )
38
+ return v.lower()
39
+
40
+
41
+ class LoginRequest(CamelModel):
42
+ email: EmailStr
43
+ password: str
44
+
45
+
46
+ class PasswordResetRequest(CamelModel):
47
+ email: EmailStr
48
+
49
+
50
+ class PasswordResetConfirmRequest(CamelModel):
51
+ token: str
52
+ new_password: str = Field(..., min_length=8, max_length=128)
53
+
54
+
55
+ class ChangePasswordRequest(CamelModel):
56
+ current_password: str
57
+ new_password: str = Field(..., min_length=8, max_length=128)
58
+
59
+
60
+ class ResendVerificationRequest(CamelModel):
61
+ email: EmailStr
62
+
63
+
64
+ # ── Response schemas ─────────────────────────────────────────────
65
+ class UserResponse(CamelModel):
66
+ id: str
67
+ email: str
68
+ username: str
69
+ full_name: str
70
+ is_active: bool
71
+ is_superuser: bool
72
+ is_verified: bool
73
+ last_login_at: Optional[str] = None
74
+ role: Optional[str] = None
75
+ permissions: List[str] = Field(default_factory=list)
76
+
77
+ class TokenResponse(CamelModel):
78
+ access_token: str
79
+ refresh_token: str = Field(exclude=True)
80
+ token_type: str = "bearer"
81
+ expires_in: int # seconds
82
+ user: UserResponse
83
+
84
+
85
+ class TokenRefreshResponse(CamelModel):
86
+ access_token: str
87
+ refresh_token: str = Field(exclude=True)
88
+ token_type: str = "bearer"
89
+ expires_in: int
90
+
91
+
92
+ class SessionResponse(CamelModel):
93
+ id: str
94
+ user_agent: Optional[str] = None
95
+ ip_address: Optional[str] = None
96
+ created_at: str
97
+ last_used_at: Optional[str] = None
98
+ expires_at: str
@@ -0,0 +1,383 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Dict, Optional
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.api.v1.auth.repository import (
8
+ EmailVerificationTokenRepository,
9
+ PasswordResetTokenRepository,
10
+ RevokedTokenRepository,
11
+ UserRepository,
12
+ UserSessionRepository,
13
+ )
14
+ from app.api.v1.auth.schema import (
15
+ ChangePasswordRequest,
16
+ LoginRequest,
17
+ PasswordResetConfirmRequest,
18
+ PasswordResetRequest,
19
+ RegisterRequest,
20
+ SessionResponse,
21
+ TokenResponse,
22
+ UserResponse,
23
+ )
24
+ from app.core.config import settings
25
+ from app.core.exceptions import (
26
+ ConflictException,
27
+ NotFoundException,
28
+ UnauthorizedException,
29
+ ValidationException,
30
+ )
31
+ from app.core.logging import get_logger
32
+ from app.core.security import (
33
+ create_access_token,
34
+ create_refresh_token,
35
+ decode_token,
36
+ generate_secure_token,
37
+ hash_opaque_token,
38
+ hash_password,
39
+ verify_password,
40
+ )
41
+ from app.services.audit import AuditService
42
+ from app.services.email import EmailService
43
+ from app.services.notification import NotificationService
44
+
45
+ logger = get_logger(__name__)
46
+
47
+
48
+ class AuthService:
49
+ def __init__(self, session: AsyncSession):
50
+ self.session = session
51
+ self.repo = UserRepository(session)
52
+ self.revoked_tokens = RevokedTokenRepository(session)
53
+ self.email_tokens = EmailVerificationTokenRepository(session)
54
+ self.password_tokens = PasswordResetTokenRepository(session)
55
+ self.sessions = UserSessionRepository(session)
56
+ self.audit = AuditService(session)
57
+ self.notifications = NotificationService(session)
58
+ self.email = EmailService()
59
+
60
+ async def register(self, data: RegisterRequest, request_meta: Dict) -> UserResponse:
61
+ if await self.repo.exists("email", data.email.lower()):
62
+ raise ConflictException("An account with this email already exists")
63
+ if await self.repo.exists("username", data.username.lower()):
64
+ raise ConflictException("Username is already taken")
65
+
66
+ user = await self.repo.create(
67
+ {
68
+ "email": data.email.lower(),
69
+ "username": data.username.lower(),
70
+ "full_name": data.full_name,
71
+ "hashed_password": hash_password(data.password),
72
+ }
73
+ )
74
+ await self.audit.log(
75
+ action="user.registered",
76
+ resource="users",
77
+ resource_id=str(user.id),
78
+ new_values={"email": user.email, "username": user.username},
79
+ **request_meta,
80
+ )
81
+ await self.notifications.send(
82
+ user_id=user.id,
83
+ title="Welcome!",
84
+ body=f"Hi {user.full_name}, your account was created successfully.",
85
+ notification_type="system",
86
+ )
87
+ # Email verification is currently disabled.
88
+ # await self.send_verification_email(user.email)
89
+
90
+ logger.info("User registered", user_id=str(user.id), email=user.email)
91
+ return self._user_response(user, [])
92
+
93
+ async def login(self, data: LoginRequest, request_meta: Dict) -> TokenResponse:
94
+ user = await self.repo.get_by_email(data.email)
95
+ if not user or not verify_password(data.password, user.hashed_password):
96
+ raise UnauthorizedException("Invalid email or password")
97
+ if not user.is_active:
98
+ raise UnauthorizedException("Account is deactivated")
99
+
100
+ login_at = datetime.now(timezone.utc)
101
+ user.last_login_at = login_at
102
+ self.session.add(user)
103
+
104
+ permissions = self.repo.collect_permissions(user)
105
+ role = self.repo.collect_role_name(user)
106
+ access_token, _ = create_access_token(
107
+ str(user.id),
108
+ {
109
+ "permissions": permissions,
110
+ "role": role,
111
+ "is_superuser": user.is_superuser,
112
+ },
113
+ )
114
+ refresh_token, refresh_expire = create_refresh_token(str(user.id))
115
+ refresh_payload = decode_token(refresh_token, expected_type="refresh")
116
+ await self.sessions.create(
117
+ {
118
+ "user_id": user.id,
119
+ "refresh_jti": refresh_payload["jti"],
120
+ "user_agent": request_meta.get("user_agent"),
121
+ "ip_address": request_meta.get("ip_address"),
122
+ "expires_at": refresh_expire,
123
+ "last_used_at": login_at,
124
+ }
125
+ )
126
+
127
+ await self.audit.log(
128
+ action="user.login",
129
+ resource="users",
130
+ resource_id=str(user.id),
131
+ **request_meta,
132
+ )
133
+ return TokenResponse(
134
+ access_token=access_token,
135
+ refresh_token=refresh_token,
136
+ expires_in=int(settings.access_token_expire_minutes * 60),
137
+ user=self._user_response(user, permissions),
138
+ )
139
+
140
+ async def refresh(
141
+ self, refresh_token: str, request_meta: Dict | None = None
142
+ ) -> Dict:
143
+ payload = decode_token(refresh_token, expected_type="refresh")
144
+ refresh_jti = payload.get("jti")
145
+ if not refresh_jti:
146
+ raise UnauthorizedException("Invalid token payload")
147
+ if await self.revoked_tokens.is_revoked(refresh_jti):
148
+ raise UnauthorizedException("Token has been revoked")
149
+
150
+ session = await self.sessions.get_active_by_refresh_jti(refresh_jti)
151
+ if not session:
152
+ replayed = await self.sessions.get_by_previous_refresh_jti(refresh_jti)
153
+ if replayed:
154
+ await self.sessions.revoke_session(replayed)
155
+ raise UnauthorizedException("Refresh session is invalid or expired")
156
+
157
+ user = await self.repo.get_with_permissions(payload.get("sub"))
158
+ if not user or not user.is_active:
159
+ raise UnauthorizedException("User not found or deactivated")
160
+
161
+ permissions = self.repo.collect_permissions(user)
162
+ role = self.repo.collect_role_name(user)
163
+ access_token, _ = create_access_token(
164
+ str(user.id),
165
+ {
166
+ "permissions": permissions,
167
+ "role": role,
168
+ "is_superuser": user.is_superuser,
169
+ },
170
+ )
171
+ new_refresh_token, refresh_expire = create_refresh_token(str(user.id))
172
+ new_refresh_payload = decode_token(new_refresh_token, expected_type="refresh")
173
+
174
+ await self._revoke_payload(payload)
175
+ session.previous_refresh_jti = refresh_jti
176
+ session.refresh_jti = new_refresh_payload["jti"]
177
+ session.expires_at = refresh_expire
178
+ session.last_used_at = datetime.now(timezone.utc)
179
+ if request_meta:
180
+ session.ip_address = request_meta.get("ip_address")
181
+ session.user_agent = request_meta.get("user_agent")
182
+ self.session.add(session)
183
+ await self.session.flush()
184
+
185
+ return {
186
+ "access_token": access_token,
187
+ "refresh_token": new_refresh_token,
188
+ "token_type": "bearer",
189
+ "expires_in": int(settings.access_token_expire_minutes * 60),
190
+ }
191
+
192
+ async def logout(
193
+ self,
194
+ access_token: Optional[str],
195
+ refresh_token: str,
196
+ request_meta: Dict,
197
+ ) -> None:
198
+ refresh_payload = decode_token(refresh_token, expected_type="refresh")
199
+ user_id = refresh_payload.get("sub")
200
+ if not user_id:
201
+ raise UnauthorizedException("Invalid token payload")
202
+
203
+ access_payload = None
204
+ if access_token:
205
+ try:
206
+ access_payload = decode_token(access_token, expected_type="access")
207
+ except UnauthorizedException:
208
+ access_payload = None
209
+
210
+ if access_payload:
211
+ if access_payload.get("sub") != user_id:
212
+ raise UnauthorizedException("Token subject mismatch")
213
+ await self._revoke_payload(access_payload)
214
+
215
+ await self._revoke_payload(refresh_payload)
216
+ session = await self.sessions.get_active_by_refresh_jti(refresh_payload["jti"])
217
+ if session:
218
+ await self.sessions.revoke_session(session)
219
+
220
+ await self.audit.log(
221
+ action="user.logout",
222
+ resource="users",
223
+ resource_id=str(user_id),
224
+ **request_meta,
225
+ )
226
+
227
+ async def send_verification_email(self, email: str) -> None:
228
+ user = await self.repo.get_by_email(email)
229
+ if not user:
230
+ return
231
+ token = generate_secure_token()
232
+ await self.email_tokens.create(
233
+ {
234
+ "user_id": user.id,
235
+ "token": hash_opaque_token(token),
236
+ "expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
237
+ }
238
+ )
239
+ try:
240
+ await self.email.send_email_verification(
241
+ user.email,
242
+ f"{settings.frontend_url.rstrip('/')}/verify-email?token={token}",
243
+ )
244
+ except Exception as exc:
245
+ logger.warning("Verification email could not be sent", error=str(exc))
246
+
247
+ async def verify_email(self, token: str, request_meta: Dict) -> None:
248
+ token_row = await self.email_tokens.get_valid(token)
249
+ if not token_row:
250
+ raise ValidationException("Invalid or expired verification token")
251
+ user = await self.repo.get_by_id(token_row.user_id)
252
+ if not user:
253
+ raise NotFoundException("User not found")
254
+ user.is_verified = True
255
+ token_row.used_at = datetime.now(timezone.utc)
256
+ self.session.add_all([user, token_row])
257
+ await self.audit.log(
258
+ action="user.email_verified",
259
+ resource="users",
260
+ resource_id=str(user.id),
261
+ **request_meta,
262
+ )
263
+
264
+ async def request_password_reset(self, data: PasswordResetRequest) -> None:
265
+ user = await self.repo.get_by_email(data.email)
266
+ if not user:
267
+ return
268
+ token = generate_secure_token()
269
+ await self.password_tokens.create(
270
+ {
271
+ "user_id": user.id,
272
+ "token": hash_opaque_token(token),
273
+ "expires_at": datetime.now(timezone.utc)
274
+ + timedelta(minutes=settings.password_reset_token_expire_minutes),
275
+ }
276
+ )
277
+ try:
278
+ await self.email.send_password_reset_email(
279
+ user.email,
280
+ f"{settings.frontend_url.rstrip('/')}/reset-password?token={token}",
281
+ )
282
+ except Exception as exc:
283
+ logger.warning("Password reset email could not be sent", error=str(exc))
284
+
285
+ async def confirm_password_reset(
286
+ self, data: PasswordResetConfirmRequest, request_meta: Dict
287
+ ) -> None:
288
+ token_row = await self.password_tokens.get_valid(data.token)
289
+ if not token_row:
290
+ raise ValidationException("Invalid or expired reset token")
291
+ user = await self.repo.get_by_id(token_row.user_id)
292
+ if not user:
293
+ raise NotFoundException("User not found")
294
+ user.hashed_password = hash_password(data.new_password)
295
+ token_row.used_at = datetime.now(timezone.utc)
296
+ self.session.add_all([user, token_row])
297
+ await self.audit.log(
298
+ action="user.password_reset",
299
+ resource="users",
300
+ resource_id=str(user.id),
301
+ **request_meta,
302
+ )
303
+
304
+ async def change_password(
305
+ self, user_id: str, data: ChangePasswordRequest, request_meta: Dict
306
+ ) -> None:
307
+ user = await self.repo.get_by_id(UUID(str(user_id)))
308
+ if not user or not verify_password(data.current_password, user.hashed_password):
309
+ raise UnauthorizedException("Current password is incorrect")
310
+ user.hashed_password = hash_password(data.new_password)
311
+ self.session.add(user)
312
+ await self.audit.log(
313
+ action="user.password_changed",
314
+ resource="users",
315
+ resource_id=str(user.id),
316
+ **request_meta,
317
+ )
318
+
319
+ async def list_sessions(self, user_id: str) -> list[SessionResponse]:
320
+ sessions = await self.sessions.list_active_for_user(user_id)
321
+ return [
322
+ SessionResponse(
323
+ id=str(item.id),
324
+ user_agent=item.user_agent,
325
+ ip_address=item.ip_address,
326
+ created_at=item.created_at.isoformat(),
327
+ last_used_at=(
328
+ item.last_used_at.isoformat() if item.last_used_at else None
329
+ ),
330
+ expires_at=item.expires_at.isoformat(),
331
+ )
332
+ for item in sessions
333
+ ]
334
+
335
+ async def revoke_session(
336
+ self, user_id: str, session_id: str, request_meta: Dict
337
+ ) -> None:
338
+ session = await self.sessions.get_by_id(UUID(str(session_id)))
339
+ if not session or str(session.user_id) != str(user_id):
340
+ raise NotFoundException("Session not found")
341
+ await self.sessions.revoke_session(session)
342
+ await self.revoked_tokens.revoke(
343
+ session.refresh_jti, "refresh", session.expires_at
344
+ )
345
+ await self.audit.log(
346
+ action="user.session_revoked",
347
+ resource="user_sessions",
348
+ resource_id=str(session.id),
349
+ user_id=user_id,
350
+ **request_meta,
351
+ )
352
+
353
+ async def _revoke_payload(self, payload: Dict) -> None:
354
+ jti = payload.get("jti")
355
+ expires_at = self._payload_expires_at(payload)
356
+ token_type = payload.get("type")
357
+ if not jti or not expires_at or not token_type:
358
+ raise UnauthorizedException("Invalid token payload")
359
+ await self.revoked_tokens.revoke(jti, token_type, expires_at)
360
+
361
+ def _payload_expires_at(self, payload: Dict) -> datetime | None:
362
+ exp = payload.get("exp")
363
+ if isinstance(exp, (int, float)):
364
+ return datetime.fromtimestamp(exp, tz=timezone.utc)
365
+ if isinstance(exp, datetime):
366
+ return exp
367
+ return None
368
+
369
+ def _user_response(self, user, permissions: list[str]) -> UserResponse:
370
+ return UserResponse(
371
+ id=str(user.id),
372
+ email=user.email,
373
+ username=user.username,
374
+ full_name=user.full_name,
375
+ is_active=user.is_active,
376
+ is_superuser=user.is_superuser,
377
+ is_verified=user.is_verified,
378
+ last_login_at=(
379
+ user.last_login_at.isoformat() if user.last_login_at else None
380
+ ),
381
+ role=self.repo.collect_role_name(user),
382
+ permissions=permissions,
383
+ )
@@ -0,0 +1,3 @@
1
+ from app.api.v1.health.router import router
2
+
3
+ __all__ = ["router"]