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,45 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from app.api.v1.audit_logs.repository import AuditLogRepository
5
+ from app.api.v1.audit_logs.schema import AuditLogResponse
6
+ from app.core.dependencies import get_async_db, require_superuser
7
+ from app.core.responses import paginated_response, success_response
8
+ from app.helper.pagination_helper import PaginationParams
9
+
10
+ router = APIRouter(dependencies=[Depends(require_superuser)])
11
+
12
+
13
+ @router.get("", summary="List audit log entries")
14
+ async def list_audit_logs(
15
+ pagination: PaginationParams = Depends(),
16
+ db: AsyncSession = Depends(get_async_db),
17
+ ):
18
+ entries, total = await AuditLogRepository(db).list(
19
+ pagination.page,
20
+ pagination.page_size,
21
+ pagination.pagination,
22
+ pagination.search,
23
+ )
24
+ items = [
25
+ AuditLogResponse(
26
+ id=entry.id,
27
+ created_at=entry.created_at,
28
+ user_id=entry.user_id,
29
+ user_email=entry.user_email,
30
+ action=entry.action,
31
+ resource=entry.resource,
32
+ resource_id=entry.resource_id,
33
+ old_values=entry.old_values,
34
+ new_values=entry.new_values,
35
+ metadata=entry.log_metadata,
36
+ ip_address=entry.ip_address,
37
+ user_agent=entry.user_agent,
38
+ request_id=entry.request_id,
39
+ ).model_dump()
40
+ for entry in entries
41
+ ]
42
+ if not pagination.pagination:
43
+ return success_response(data=items)
44
+ return paginated_response(items, total, pagination.page, pagination.page_size)
45
+
@@ -0,0 +1,21 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+ from uuid import UUID
4
+
5
+ from app.utils.casing import CamelModel
6
+
7
+
8
+ class AuditLogResponse(CamelModel):
9
+ id: UUID
10
+ created_at: datetime
11
+ user_id: UUID | None = None
12
+ user_email: str | None = None
13
+ action: str
14
+ resource: str
15
+ resource_id: str | None = None
16
+ old_values: dict[str, Any] | None = None
17
+ new_values: dict[str, Any] | None = None
18
+ metadata: dict[str, Any] | None = None
19
+ ip_address: str | None = None
20
+ user_agent: str | None = None
21
+ request_id: str | None = None
@@ -0,0 +1,127 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy import delete, exists, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.db.models import (
9
+ EmailVerificationToken,
10
+ PasswordResetToken,
11
+ RevokedToken,
12
+ User,
13
+ UserSession,
14
+ )
15
+ from app.repositories.base import BaseRepository
16
+ from app.core.security import hash_opaque_token
17
+
18
+
19
+ class UserRepository(BaseRepository[User]):
20
+ def __init__(self, session: AsyncSession):
21
+ super().__init__(User, session)
22
+
23
+ async def get_by_email(self, email: str) -> Optional[User]:
24
+ result = await self.session.execute(
25
+ select(User).where(User.email == email.lower(), User.is_deleted == False)
26
+ )
27
+ return result.scalar_one_or_none()
28
+
29
+ async def get_active(self, user_id: UUID | str) -> Optional[User]:
30
+ try:
31
+ user_id = UUID(str(user_id))
32
+ except ValueError:
33
+ return None
34
+ result = await self.session.execute(
35
+ select(User).where(User.id == user_id, User.is_deleted == False)
36
+ )
37
+ return result.scalar_one_or_none()
38
+
39
+ class RevokedTokenRepository(BaseRepository[RevokedToken]):
40
+ def __init__(self, session: AsyncSession):
41
+ super().__init__(RevokedToken, session)
42
+
43
+ async def revoke(self, jti: str, token_type: str, expires_at: datetime) -> None:
44
+ if not await self.is_revoked(jti):
45
+ self.session.add(
46
+ RevokedToken(jti=jti, token_type=token_type, expires_at=expires_at)
47
+ )
48
+ await self.session.flush()
49
+
50
+ async def is_revoked(self, jti: str) -> bool:
51
+ await self.session.execute(
52
+ delete(RevokedToken).where(
53
+ RevokedToken.expires_at <= datetime.now(timezone.utc)
54
+ )
55
+ )
56
+ result = await self.session.execute(
57
+ select(exists().where(RevokedToken.jti == jti))
58
+ )
59
+ return bool(result.scalar())
60
+
61
+
62
+ class EmailVerificationTokenRepository(BaseRepository[EmailVerificationToken]):
63
+ def __init__(self, session: AsyncSession):
64
+ super().__init__(EmailVerificationToken, session)
65
+
66
+ async def get_valid(self, token: str):
67
+ result = await self.session.execute(
68
+ select(EmailVerificationToken).where(
69
+ EmailVerificationToken.token == hash_opaque_token(token),
70
+ EmailVerificationToken.used_at.is_(None),
71
+ EmailVerificationToken.expires_at > datetime.now(timezone.utc),
72
+ )
73
+ )
74
+ return result.scalar_one_or_none()
75
+
76
+
77
+ class PasswordResetTokenRepository(BaseRepository[PasswordResetToken]):
78
+ def __init__(self, session: AsyncSession):
79
+ super().__init__(PasswordResetToken, session)
80
+
81
+ async def get_valid(self, token: str):
82
+ result = await self.session.execute(
83
+ select(PasswordResetToken).where(
84
+ PasswordResetToken.token == hash_opaque_token(token),
85
+ PasswordResetToken.used_at.is_(None),
86
+ PasswordResetToken.expires_at > datetime.now(timezone.utc),
87
+ )
88
+ )
89
+ return result.scalar_one_or_none()
90
+
91
+
92
+ class UserSessionRepository(BaseRepository[UserSession]):
93
+ def __init__(self, session: AsyncSession):
94
+ super().__init__(UserSession, session)
95
+
96
+ async def get_active_by_refresh_jti(self, jti: str):
97
+ result = await self.session.execute(
98
+ select(UserSession).where(
99
+ UserSession.refresh_jti == jti,
100
+ UserSession.revoked_at.is_(None),
101
+ UserSession.expires_at > datetime.now(timezone.utc),
102
+ )
103
+ )
104
+ return result.scalar_one_or_none()
105
+
106
+ async def get_by_previous_refresh_jti(self, jti: str):
107
+ result = await self.session.execute(
108
+ select(UserSession).where(UserSession.previous_refresh_jti == jti)
109
+ )
110
+ return result.scalar_one_or_none()
111
+
112
+ async def list_active_for_user(self, user_id: UUID | str):
113
+ result = await self.session.execute(
114
+ select(UserSession)
115
+ .where(
116
+ UserSession.user_id == UUID(str(user_id)),
117
+ UserSession.revoked_at.is_(None),
118
+ UserSession.expires_at > datetime.now(timezone.utc),
119
+ )
120
+ .order_by(UserSession.created_at.desc())
121
+ )
122
+ return list(result.scalars().all())
123
+
124
+ async def revoke_session(self, session: UserSession) -> None:
125
+ session.revoked_at = datetime.now(timezone.utc)
126
+ self.session.add(session)
127
+ await self.session.flush()
@@ -0,0 +1,207 @@
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_active(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
+ )
207
+ return success_response(data=response.model_dump())
@@ -0,0 +1,96 @@
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
+
75
+ class TokenResponse(CamelModel):
76
+ access_token: str
77
+ refresh_token: str = Field(exclude=True)
78
+ token_type: str = "bearer"
79
+ expires_in: int # seconds
80
+ user: UserResponse
81
+
82
+
83
+ class TokenRefreshResponse(CamelModel):
84
+ access_token: str
85
+ refresh_token: str = Field(exclude=True)
86
+ token_type: str = "bearer"
87
+ expires_in: int
88
+
89
+
90
+ class SessionResponse(CamelModel):
91
+ id: str
92
+ user_agent: Optional[str] = None
93
+ ip_address: Optional[str] = None
94
+ created_at: str
95
+ last_used_at: Optional[str] = None
96
+ expires_at: str