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,373 @@
|
|
|
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
|
+
access_token, _ = create_access_token(
|
|
105
|
+
str(user.id),
|
|
106
|
+
{
|
|
107
|
+
"is_superuser": user.is_superuser,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
refresh_token, refresh_expire = create_refresh_token(str(user.id))
|
|
111
|
+
refresh_payload = decode_token(refresh_token, expected_type="refresh")
|
|
112
|
+
await self.sessions.create(
|
|
113
|
+
{
|
|
114
|
+
"user_id": user.id,
|
|
115
|
+
"refresh_jti": refresh_payload["jti"],
|
|
116
|
+
"user_agent": request_meta.get("user_agent"),
|
|
117
|
+
"ip_address": request_meta.get("ip_address"),
|
|
118
|
+
"expires_at": refresh_expire,
|
|
119
|
+
"last_used_at": login_at,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
await self.audit.log(
|
|
124
|
+
action="user.login",
|
|
125
|
+
resource="users",
|
|
126
|
+
resource_id=str(user.id),
|
|
127
|
+
**request_meta,
|
|
128
|
+
)
|
|
129
|
+
return TokenResponse(
|
|
130
|
+
access_token=access_token,
|
|
131
|
+
refresh_token=refresh_token,
|
|
132
|
+
expires_in=int(settings.access_token_expire_minutes * 60),
|
|
133
|
+
user=self._user_response(user),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
async def refresh(
|
|
137
|
+
self, refresh_token: str, request_meta: Dict | None = None
|
|
138
|
+
) -> Dict:
|
|
139
|
+
payload = decode_token(refresh_token, expected_type="refresh")
|
|
140
|
+
refresh_jti = payload.get("jti")
|
|
141
|
+
if not refresh_jti:
|
|
142
|
+
raise UnauthorizedException("Invalid token payload")
|
|
143
|
+
if await self.revoked_tokens.is_revoked(refresh_jti):
|
|
144
|
+
raise UnauthorizedException("Token has been revoked")
|
|
145
|
+
|
|
146
|
+
session = await self.sessions.get_active_by_refresh_jti(refresh_jti)
|
|
147
|
+
if not session:
|
|
148
|
+
replayed = await self.sessions.get_by_previous_refresh_jti(refresh_jti)
|
|
149
|
+
if replayed:
|
|
150
|
+
await self.sessions.revoke_session(replayed)
|
|
151
|
+
raise UnauthorizedException("Refresh session is invalid or expired")
|
|
152
|
+
|
|
153
|
+
user = await self.repo.get_active(payload.get("sub"))
|
|
154
|
+
if not user or not user.is_active:
|
|
155
|
+
raise UnauthorizedException("User not found or deactivated")
|
|
156
|
+
|
|
157
|
+
access_token, _ = create_access_token(
|
|
158
|
+
str(user.id),
|
|
159
|
+
{
|
|
160
|
+
"is_superuser": user.is_superuser,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
new_refresh_token, refresh_expire = create_refresh_token(str(user.id))
|
|
164
|
+
new_refresh_payload = decode_token(new_refresh_token, expected_type="refresh")
|
|
165
|
+
|
|
166
|
+
await self._revoke_payload(payload)
|
|
167
|
+
session.previous_refresh_jti = refresh_jti
|
|
168
|
+
session.refresh_jti = new_refresh_payload["jti"]
|
|
169
|
+
session.expires_at = refresh_expire
|
|
170
|
+
session.last_used_at = datetime.now(timezone.utc)
|
|
171
|
+
if request_meta:
|
|
172
|
+
session.ip_address = request_meta.get("ip_address")
|
|
173
|
+
session.user_agent = request_meta.get("user_agent")
|
|
174
|
+
self.session.add(session)
|
|
175
|
+
await self.session.flush()
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"access_token": access_token,
|
|
179
|
+
"refresh_token": new_refresh_token,
|
|
180
|
+
"token_type": "bearer",
|
|
181
|
+
"expires_in": int(settings.access_token_expire_minutes * 60),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async def logout(
|
|
185
|
+
self,
|
|
186
|
+
access_token: Optional[str],
|
|
187
|
+
refresh_token: str,
|
|
188
|
+
request_meta: Dict,
|
|
189
|
+
) -> None:
|
|
190
|
+
refresh_payload = decode_token(refresh_token, expected_type="refresh")
|
|
191
|
+
user_id = refresh_payload.get("sub")
|
|
192
|
+
if not user_id:
|
|
193
|
+
raise UnauthorizedException("Invalid token payload")
|
|
194
|
+
|
|
195
|
+
access_payload = None
|
|
196
|
+
if access_token:
|
|
197
|
+
try:
|
|
198
|
+
access_payload = decode_token(access_token, expected_type="access")
|
|
199
|
+
except UnauthorizedException:
|
|
200
|
+
access_payload = None
|
|
201
|
+
|
|
202
|
+
if access_payload:
|
|
203
|
+
if access_payload.get("sub") != user_id:
|
|
204
|
+
raise UnauthorizedException("Token subject mismatch")
|
|
205
|
+
await self._revoke_payload(access_payload)
|
|
206
|
+
|
|
207
|
+
await self._revoke_payload(refresh_payload)
|
|
208
|
+
session = await self.sessions.get_active_by_refresh_jti(refresh_payload["jti"])
|
|
209
|
+
if session:
|
|
210
|
+
await self.sessions.revoke_session(session)
|
|
211
|
+
|
|
212
|
+
await self.audit.log(
|
|
213
|
+
action="user.logout",
|
|
214
|
+
resource="users",
|
|
215
|
+
resource_id=str(user_id),
|
|
216
|
+
**request_meta,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def send_verification_email(self, email: str) -> None:
|
|
220
|
+
user = await self.repo.get_by_email(email)
|
|
221
|
+
if not user:
|
|
222
|
+
return
|
|
223
|
+
token = generate_secure_token()
|
|
224
|
+
await self.email_tokens.create(
|
|
225
|
+
{
|
|
226
|
+
"user_id": user.id,
|
|
227
|
+
"token": hash_opaque_token(token),
|
|
228
|
+
"expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
try:
|
|
232
|
+
await self.email.send_email_verification(
|
|
233
|
+
user.email,
|
|
234
|
+
f"{settings.frontend_url.rstrip('/')}/verify-email?token={token}",
|
|
235
|
+
)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
logger.warning("Verification email could not be sent", error=str(exc))
|
|
238
|
+
|
|
239
|
+
async def verify_email(self, token: str, request_meta: Dict) -> None:
|
|
240
|
+
token_row = await self.email_tokens.get_valid(token)
|
|
241
|
+
if not token_row:
|
|
242
|
+
raise ValidationException("Invalid or expired verification token")
|
|
243
|
+
user = await self.repo.get_by_id(token_row.user_id)
|
|
244
|
+
if not user:
|
|
245
|
+
raise NotFoundException("User not found")
|
|
246
|
+
user.is_verified = True
|
|
247
|
+
token_row.used_at = datetime.now(timezone.utc)
|
|
248
|
+
self.session.add_all([user, token_row])
|
|
249
|
+
await self.audit.log(
|
|
250
|
+
action="user.email_verified",
|
|
251
|
+
resource="users",
|
|
252
|
+
resource_id=str(user.id),
|
|
253
|
+
**request_meta,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def request_password_reset(self, data: PasswordResetRequest) -> None:
|
|
257
|
+
user = await self.repo.get_by_email(data.email)
|
|
258
|
+
if not user:
|
|
259
|
+
return
|
|
260
|
+
token = generate_secure_token()
|
|
261
|
+
await self.password_tokens.create(
|
|
262
|
+
{
|
|
263
|
+
"user_id": user.id,
|
|
264
|
+
"token": hash_opaque_token(token),
|
|
265
|
+
"expires_at": datetime.now(timezone.utc)
|
|
266
|
+
+ timedelta(minutes=settings.password_reset_token_expire_minutes),
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
await self.email.send_password_reset_email(
|
|
271
|
+
user.email,
|
|
272
|
+
f"{settings.frontend_url.rstrip('/')}/reset-password?token={token}",
|
|
273
|
+
)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
logger.warning("Password reset email could not be sent", error=str(exc))
|
|
276
|
+
|
|
277
|
+
async def confirm_password_reset(
|
|
278
|
+
self, data: PasswordResetConfirmRequest, request_meta: Dict
|
|
279
|
+
) -> None:
|
|
280
|
+
token_row = await self.password_tokens.get_valid(data.token)
|
|
281
|
+
if not token_row:
|
|
282
|
+
raise ValidationException("Invalid or expired reset token")
|
|
283
|
+
user = await self.repo.get_by_id(token_row.user_id)
|
|
284
|
+
if not user:
|
|
285
|
+
raise NotFoundException("User not found")
|
|
286
|
+
user.hashed_password = hash_password(data.new_password)
|
|
287
|
+
token_row.used_at = datetime.now(timezone.utc)
|
|
288
|
+
self.session.add_all([user, token_row])
|
|
289
|
+
await self.audit.log(
|
|
290
|
+
action="user.password_reset",
|
|
291
|
+
resource="users",
|
|
292
|
+
resource_id=str(user.id),
|
|
293
|
+
**request_meta,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def change_password(
|
|
297
|
+
self, user_id: str, data: ChangePasswordRequest, request_meta: Dict
|
|
298
|
+
) -> None:
|
|
299
|
+
user = await self.repo.get_by_id(UUID(str(user_id)))
|
|
300
|
+
if not user or not verify_password(data.current_password, user.hashed_password):
|
|
301
|
+
raise UnauthorizedException("Current password is incorrect")
|
|
302
|
+
user.hashed_password = hash_password(data.new_password)
|
|
303
|
+
self.session.add(user)
|
|
304
|
+
await self.audit.log(
|
|
305
|
+
action="user.password_changed",
|
|
306
|
+
resource="users",
|
|
307
|
+
resource_id=str(user.id),
|
|
308
|
+
**request_meta,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def list_sessions(self, user_id: str) -> list[SessionResponse]:
|
|
312
|
+
sessions = await self.sessions.list_active_for_user(user_id)
|
|
313
|
+
return [
|
|
314
|
+
SessionResponse(
|
|
315
|
+
id=str(item.id),
|
|
316
|
+
user_agent=item.user_agent,
|
|
317
|
+
ip_address=item.ip_address,
|
|
318
|
+
created_at=item.created_at.isoformat(),
|
|
319
|
+
last_used_at=(
|
|
320
|
+
item.last_used_at.isoformat() if item.last_used_at else None
|
|
321
|
+
),
|
|
322
|
+
expires_at=item.expires_at.isoformat(),
|
|
323
|
+
)
|
|
324
|
+
for item in sessions
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
async def revoke_session(
|
|
328
|
+
self, user_id: str, session_id: str, request_meta: Dict
|
|
329
|
+
) -> None:
|
|
330
|
+
session = await self.sessions.get_by_id(UUID(str(session_id)))
|
|
331
|
+
if not session or str(session.user_id) != str(user_id):
|
|
332
|
+
raise NotFoundException("Session not found")
|
|
333
|
+
await self.sessions.revoke_session(session)
|
|
334
|
+
await self.revoked_tokens.revoke(
|
|
335
|
+
session.refresh_jti, "refresh", session.expires_at
|
|
336
|
+
)
|
|
337
|
+
await self.audit.log(
|
|
338
|
+
action="user.session_revoked",
|
|
339
|
+
resource="user_sessions",
|
|
340
|
+
resource_id=str(session.id),
|
|
341
|
+
user_id=user_id,
|
|
342
|
+
**request_meta,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
async def _revoke_payload(self, payload: Dict) -> None:
|
|
346
|
+
jti = payload.get("jti")
|
|
347
|
+
expires_at = self._payload_expires_at(payload)
|
|
348
|
+
token_type = payload.get("type")
|
|
349
|
+
if not jti or not expires_at or not token_type:
|
|
350
|
+
raise UnauthorizedException("Invalid token payload")
|
|
351
|
+
await self.revoked_tokens.revoke(jti, token_type, expires_at)
|
|
352
|
+
|
|
353
|
+
def _payload_expires_at(self, payload: Dict) -> datetime | None:
|
|
354
|
+
exp = payload.get("exp")
|
|
355
|
+
if isinstance(exp, (int, float)):
|
|
356
|
+
return datetime.fromtimestamp(exp, tz=timezone.utc)
|
|
357
|
+
if isinstance(exp, datetime):
|
|
358
|
+
return exp
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
def _user_response(self, user) -> UserResponse:
|
|
362
|
+
return UserResponse(
|
|
363
|
+
id=str(user.id),
|
|
364
|
+
email=user.email,
|
|
365
|
+
username=user.username,
|
|
366
|
+
full_name=user.full_name,
|
|
367
|
+
is_active=user.is_active,
|
|
368
|
+
is_superuser=user.is_superuser,
|
|
369
|
+
is_verified=user.is_verified,
|
|
370
|
+
last_login_at=(
|
|
371
|
+
user.last_login_at.isoformat() if user.last_login_at else None
|
|
372
|
+
),
|
|
373
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fastapi import APIRouter, Response, status
|
|
2
|
+
|
|
3
|
+
from app.api.v1.health.service import HealthService
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/health")
|
|
9
|
+
async def health_check():
|
|
10
|
+
return await HealthService().status()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/health/live")
|
|
14
|
+
async def liveness_check():
|
|
15
|
+
return HealthService().liveness()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/health/ready")
|
|
19
|
+
async def readiness_check(response: Response):
|
|
20
|
+
result = await HealthService().readiness()
|
|
21
|
+
if result["status"] != "healthy":
|
|
22
|
+
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
23
|
+
return result
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from app.core.config import settings
|
|
2
|
+
from app.db.session import check_db_health
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HealthService:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def liveness() -> dict:
|
|
8
|
+
return {"status": "healthy"}
|
|
9
|
+
|
|
10
|
+
async def readiness(self) -> dict:
|
|
11
|
+
database = await check_db_health()
|
|
12
|
+
return {
|
|
13
|
+
"status": "healthy" if database["status"] == "healthy" else "unhealthy",
|
|
14
|
+
"database": database,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async def status(self) -> dict:
|
|
18
|
+
database = await check_db_health()
|
|
19
|
+
return {
|
|
20
|
+
"status": "healthy" if database["status"] == "healthy" else "degraded",
|
|
21
|
+
"app": settings.APP_NAME,
|
|
22
|
+
"version": settings.APP_VERSION,
|
|
23
|
+
"environment": settings.APP_ENV,
|
|
24
|
+
"database": database,
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import and_, func, select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.db.models import User
|
|
7
|
+
from app.helper.pagination_helper import apply_pagination
|
|
8
|
+
from app.helper.search import text_search_filter
|
|
9
|
+
from app.helper.sorting import sort_expressions
|
|
10
|
+
from app.repositories.base import BaseRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserRepository(BaseRepository[User]):
|
|
14
|
+
def __init__(self, session: AsyncSession):
|
|
15
|
+
super().__init__(User, session)
|
|
16
|
+
|
|
17
|
+
async def get_active(self, user_id: UUID):
|
|
18
|
+
result = await self.session.execute(
|
|
19
|
+
select(User).where(
|
|
20
|
+
User.id == user_id,
|
|
21
|
+
User.is_deleted == False,
|
|
22
|
+
User.is_active == True,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
return result.scalar_one_or_none()
|
|
26
|
+
|
|
27
|
+
async def get_all(
|
|
28
|
+
self,
|
|
29
|
+
page: int,
|
|
30
|
+
page_size: int,
|
|
31
|
+
pagination: bool = True,
|
|
32
|
+
search: str | None = None,
|
|
33
|
+
sort_by: str | None = None,
|
|
34
|
+
sort_order: str | None = None,
|
|
35
|
+
):
|
|
36
|
+
query = select(User).where(
|
|
37
|
+
User.is_deleted == False, User.is_active == True
|
|
38
|
+
)
|
|
39
|
+
search_filter = text_search_filter(
|
|
40
|
+
User, search, [User.email, User.username, User.full_name]
|
|
41
|
+
)
|
|
42
|
+
if search_filter is not None:
|
|
43
|
+
query = query.where(search_filter)
|
|
44
|
+
total = (
|
|
45
|
+
await self.session.execute(
|
|
46
|
+
select(func.count()).select_from(query.subquery())
|
|
47
|
+
)
|
|
48
|
+
).scalar_one()
|
|
49
|
+
query = query.order_by(
|
|
50
|
+
*sort_expressions(User, sort_by, sort_order, (User.created_at.desc(),))
|
|
51
|
+
)
|
|
52
|
+
result = await self.session.execute(
|
|
53
|
+
apply_pagination(query, page, page_size, pagination)
|
|
54
|
+
)
|
|
55
|
+
return list(result.scalars().all()), total
|
|
56
|
+
|
|
57
|
+
async def exists_for_other_user(
|
|
58
|
+
self, field: str, value: str, user_id: UUID
|
|
59
|
+
) -> bool:
|
|
60
|
+
result = await self.session.execute(
|
|
61
|
+
select(User).where(
|
|
62
|
+
and_(
|
|
63
|
+
getattr(User, field) == value,
|
|
64
|
+
User.id != user_id,
|
|
65
|
+
User.is_deleted == False,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return result.scalar_one_or_none() is not None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, status
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.api.v1.schema import DeleteNoteRequest
|
|
7
|
+
from app.api.v1.users.schema import UserCreate, UserUpdate
|
|
8
|
+
from app.api.v1.users.service import UserService
|
|
9
|
+
from app.core.dependencies import get_async_db, require_superuser
|
|
10
|
+
from app.core.responses import paginated_response, success_response
|
|
11
|
+
from app.helper.pagination_helper import PaginationParams
|
|
12
|
+
|
|
13
|
+
router = APIRouter(dependencies=[Depends(require_superuser)])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("")
|
|
17
|
+
async def list_users(
|
|
18
|
+
pagination: PaginationParams = Depends(),
|
|
19
|
+
db: AsyncSession = Depends(get_async_db),
|
|
20
|
+
):
|
|
21
|
+
data, total = await UserService(db).list_users(
|
|
22
|
+
pagination.page,
|
|
23
|
+
pagination.page_size,
|
|
24
|
+
pagination.pagination,
|
|
25
|
+
search=pagination.search,
|
|
26
|
+
sort_by=pagination.sort_by,
|
|
27
|
+
sort_order=pagination.sort_order,
|
|
28
|
+
)
|
|
29
|
+
items = [item.model_dump() for item in data]
|
|
30
|
+
if not pagination.pagination:
|
|
31
|
+
return success_response(data=items)
|
|
32
|
+
return paginated_response(items, total, pagination.page, pagination.page_size)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/deleted")
|
|
36
|
+
async def list_deleted_users(
|
|
37
|
+
pagination: PaginationParams = Depends(),
|
|
38
|
+
db: AsyncSession = Depends(get_async_db),
|
|
39
|
+
):
|
|
40
|
+
data, total = await UserService(db).list_deleted_users(
|
|
41
|
+
pagination.page,
|
|
42
|
+
pagination.page_size,
|
|
43
|
+
pagination.pagination,
|
|
44
|
+
search=pagination.search,
|
|
45
|
+
sort_by=pagination.sort_by,
|
|
46
|
+
sort_order=pagination.sort_order,
|
|
47
|
+
)
|
|
48
|
+
items = [item.model_dump() for item in data]
|
|
49
|
+
if not pagination.pagination:
|
|
50
|
+
return success_response(data=items)
|
|
51
|
+
return paginated_response(items, total, pagination.page, pagination.page_size)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
55
|
+
async def create_user(
|
|
56
|
+
body: UserCreate, db: AsyncSession = Depends(get_async_db)
|
|
57
|
+
):
|
|
58
|
+
user = await UserService(db).create_user(body)
|
|
59
|
+
return success_response(data=user.model_dump(), message="User created")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.get("/{user_id}")
|
|
63
|
+
async def get_user(user_id: UUID, db: AsyncSession = Depends(get_async_db)):
|
|
64
|
+
user = await UserService(db).get_user(user_id)
|
|
65
|
+
return success_response(data=user.model_dump())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.patch("/{user_id}")
|
|
69
|
+
async def update_user(
|
|
70
|
+
user_id: UUID, body: UserUpdate, db: AsyncSession = Depends(get_async_db)
|
|
71
|
+
):
|
|
72
|
+
user = await UserService(db).update_user(user_id, body)
|
|
73
|
+
return success_response(data=user.model_dump(), message="User updated")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.delete("/{user_id}")
|
|
77
|
+
async def soft_delete_user(
|
|
78
|
+
user_id: UUID,
|
|
79
|
+
body: DeleteNoteRequest,
|
|
80
|
+
db: AsyncSession = Depends(get_async_db),
|
|
81
|
+
):
|
|
82
|
+
await UserService(db).soft_delete_user(user_id, body.note)
|
|
83
|
+
return success_response(message="User soft deleted")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.delete("/{user_id}/hard")
|
|
87
|
+
async def hard_delete_user(
|
|
88
|
+
user_id: UUID,
|
|
89
|
+
body: DeleteNoteRequest,
|
|
90
|
+
db: AsyncSession = Depends(get_async_db),
|
|
91
|
+
):
|
|
92
|
+
await UserService(db).hard_delete_user(user_id, body.note)
|
|
93
|
+
return success_response(message="User hard deleted")
|
|
94
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import EmailStr, Field, field_validator
|
|
5
|
+
|
|
6
|
+
from app.utils.casing import CamelModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UserBase(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
|
+
is_active: bool = True
|
|
14
|
+
is_superuser: bool = False
|
|
15
|
+
is_verified: bool = False
|
|
16
|
+
|
|
17
|
+
@field_validator("username")
|
|
18
|
+
@classmethod
|
|
19
|
+
def validate_username(cls, value: str) -> str:
|
|
20
|
+
if not re.match(r"^[a-zA-Z0-9_]+$", value):
|
|
21
|
+
raise ValueError("Username may only contain letters, digits, and underscores")
|
|
22
|
+
return value.lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UserCreate(UserBase):
|
|
26
|
+
password: str = Field(..., min_length=8, max_length=128)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UserUpdate(CamelModel):
|
|
30
|
+
email: Optional[EmailStr] = None
|
|
31
|
+
username: Optional[str] = Field(default=None, min_length=3, max_length=50)
|
|
32
|
+
full_name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
|
33
|
+
is_active: Optional[bool] = None
|
|
34
|
+
is_superuser: Optional[bool] = None
|
|
35
|
+
is_verified: Optional[bool] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UserResponse(CamelModel):
|
|
39
|
+
id: str
|
|
40
|
+
email: str
|
|
41
|
+
username: str
|
|
42
|
+
full_name: str
|
|
43
|
+
is_active: bool
|
|
44
|
+
is_superuser: bool
|
|
45
|
+
is_verified: bool
|
|
46
|
+
last_login_at: Optional[str] = None
|
|
47
|
+
is_deleted: bool = False
|
|
48
|
+
deleted_at: Optional[str] = None
|
|
49
|
+
deletion_note: Optional[str] = None
|
|
50
|
+
|