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.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- 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
|
|
File without changes
|
|
@@ -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
|