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,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
|
+
)
|