hinbert-fastapi 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.
- app/__init__.py +8 -0
- app/api/__init__.py +1 -0
- app/api/deps/__init__.py +1 -0
- app/api/deps/auth.py +40 -0
- app/api/deps/pagination.py +8 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/endpoints/__init__.py +1 -0
- app/api/v1/endpoints/auth.py +255 -0
- app/api/v1/endpoints/dashboard.py +23 -0
- app/api/v1/endpoints/products.py +102 -0
- app/api/v1/endpoints/users.py +89 -0
- app/api/v1/routers/__init__.py +1 -0
- app/api/v1/routers/api_router.py +11 -0
- app/core/__init__.py +1 -0
- app/core/config/__init__.py +1 -0
- app/core/config/database.py +13 -0
- app/core/config/settings.py +83 -0
- app/core/exceptions/__init__.py +1 -0
- app/core/exceptions/base_exception.py +9 -0
- app/core/exceptions/custom_exceptions.py +17 -0
- app/core/middleware/__init__.py +1 -0
- app/core/middleware/cors.py +5 -0
- app/core/middleware/error_handler.py +23 -0
- app/core/middleware/logging.py +29 -0
- app/core/middleware/rate_limit.py +27 -0
- app/core/security/__init__.py +1 -0
- app/core/security/auth.py +8 -0
- app/core/security/jwt.py +33 -0
- app/core/security/oauth.py +8 -0
- app/core/security/password.py +18 -0
- app/core/security/totp.py +13 -0
- app/db/__init__.py +1 -0
- app/db/base.py +7 -0
- app/db/migrations/env.py +39 -0
- app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py +49 -0
- app/db/migrations/versions/f8b632aa11da_initial_migration.py +113 -0
- app/db/session.py +11 -0
- app/main.py +50 -0
- app/models/__init__.py +10 -0
- app/models/domain/__init__.py +1 -0
- app/models/domain/base.py +20 -0
- app/models/domain/email_verification.py +24 -0
- app/models/domain/password_reset.py +24 -0
- app/models/domain/product.py +22 -0
- app/models/domain/refresh_token.py +24 -0
- app/models/domain/totp_secret.py +44 -0
- app/models/domain/user.py +34 -0
- app/models/schemas/__init__.py +1 -0
- app/models/schemas/auth.py +30 -0
- app/models/schemas/password.py +16 -0
- app/models/schemas/product.py +31 -0
- app/models/schemas/response.py +17 -0
- app/models/schemas/token.py +17 -0
- app/models/schemas/totp.py +16 -0
- app/models/schemas/user.py +37 -0
- app/repositories/__init__.py +1 -0
- app/repositories/base/__init__.py +1 -0
- app/repositories/base/base_repository.py +51 -0
- app/repositories/email_verification_repository.py +37 -0
- app/repositories/password_reset_repository.py +33 -0
- app/repositories/product_repository.py +40 -0
- app/repositories/refresh_token_repository.py +40 -0
- app/repositories/totp_secret_repository.py +23 -0
- app/repositories/user_repository.py +44 -0
- app/services/__init__.py +1 -0
- app/services/auth_service.py +32 -0
- app/services/base/__init__.py +1 -0
- app/services/base/base_service.py +21 -0
- app/services/email_service.py +32 -0
- app/services/product_service.py +24 -0
- app/services/social_auth_service.py +47 -0
- app/services/totp_service.py +13 -0
- app/services/user_service.py +27 -0
- app/tests/__init__.py +1 -0
- app/tests/conftest.py +36 -0
- app/tests/integration/test_auth_api.py +115 -0
- app/tests/integration/test_product_api.py +58 -0
- app/tests/integration/test_user_api.py +68 -0
- app/tests/unit/test_auth_service.py +48 -0
- app/tests/unit/test_product_service.py +33 -0
- app/tests/unit/test_user_service.py +32 -0
- app/utils/__init__.py +1 -0
- app/utils/date_utils.py +8 -0
- app/utils/file_utils.py +8 -0
- app/utils/logger.py +11 -0
- app/utils/validators.py +13 -0
- hinbert_fastapi-0.1.0.dist-info/METADATA +0 -0
- hinbert_fastapi-0.1.0.dist-info/RECORD +96 -0
- hinbert_fastapi-0.1.0.dist-info/WHEEL +5 -0
- hinbert_fastapi-0.1.0.dist-info/entry_points.txt +5 -0
- hinbert_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- hinbert_fastapi-0.1.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +1 -0
- scripts/create_admin.py +41 -0
- scripts/run_migrations.py +13 -0
- scripts/seed_data.py +44 -0
app/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Hinbert FastAPI application package.
|
|
2
|
+
|
|
3
|
+
This package contains the HTTP application and its independently testable layers.
|
|
4
|
+
It exists so the boilerplate can be installed as a reusable PyPI package while
|
|
5
|
+
still permitting teams to replace individual modules with project-specific code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API namespace."""
|
app/api/deps/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reusable FastAPI dependencies."""
|
app/api/deps/auth.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Bearer-token dependencies for protected routes."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from app.core.exceptions.custom_exceptions import UnauthorizedError
|
|
10
|
+
from app.core.security.jwt import decode_token
|
|
11
|
+
from app.db.session import get_db
|
|
12
|
+
from app.models.domain.user import User
|
|
13
|
+
|
|
14
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_current_user(token: str = Depends(oauth2_scheme), session: AsyncSession = Depends(get_db)) -> User:
|
|
18
|
+
"""Decode an access token and load its user, failing closed on any error."""
|
|
19
|
+
try:
|
|
20
|
+
subject = UUID(decode_token(token)["sub"])
|
|
21
|
+
except (ValueError, KeyError) as exc:
|
|
22
|
+
raise UnauthorizedError() from exc
|
|
23
|
+
user = await session.get(User, subject)
|
|
24
|
+
if user is None:
|
|
25
|
+
raise UnauthorizedError()
|
|
26
|
+
return user
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def get_current_active_user(user: User = Depends(get_current_user)) -> User:
|
|
30
|
+
"""Reject deactivated accounts before protected business operations."""
|
|
31
|
+
if not user.is_active:
|
|
32
|
+
raise UnauthorizedError("Inactive account")
|
|
33
|
+
return user
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_current_admin(user: User = Depends(get_current_active_user)) -> User:
|
|
37
|
+
"""Require an active account with administrator privileges."""
|
|
38
|
+
if not user.is_admin:
|
|
39
|
+
raise UnauthorizedError("Administrator privileges required")
|
|
40
|
+
return user
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Validated pagination dependency shared by collection endpoints."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Query
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pagination(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)) -> tuple[int, int]:
|
|
7
|
+
"""Return SQL offset and bounded page size."""
|
|
8
|
+
return (page - 1) * page_size, page_size
|
app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Version one API namespace."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Version one endpoint modules."""
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Authentication endpoints for credentials, recovery, MFA, and OAuth."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from app.api.deps.auth import get_current_active_user
|
|
12
|
+
from app.core.config.settings import get_settings
|
|
13
|
+
from app.core.exceptions.custom_exceptions import UnauthorizedError
|
|
14
|
+
from app.core.middleware.rate_limit import limiter
|
|
15
|
+
from app.core.security.password import hash_password, verify_password
|
|
16
|
+
from app.core.security.totp import new_secret, verify_code
|
|
17
|
+
from app.db.session import get_db
|
|
18
|
+
from app.models.domain.email_verification import EmailVerification
|
|
19
|
+
from app.models.domain.password_reset import PasswordReset
|
|
20
|
+
from app.models.domain.refresh_token import RefreshToken
|
|
21
|
+
from app.models.domain.totp_secret import TotpSecret
|
|
22
|
+
from app.models.domain.user import User
|
|
23
|
+
from app.models.schemas.auth import EmailTokenRequest, LoginRequest, SignupRequest
|
|
24
|
+
from app.models.schemas.password import ForgotPassword, ResetPassword
|
|
25
|
+
from app.models.schemas.response import APIResponse
|
|
26
|
+
from app.models.schemas.token import RefreshRequest, TokenResponse
|
|
27
|
+
from app.models.schemas.totp import TotpSetupResponse, TotpVerify
|
|
28
|
+
from app.models.schemas.user import UserOut
|
|
29
|
+
from app.repositories.email_verification_repository import create as create_verification
|
|
30
|
+
from app.repositories.email_verification_repository import get_by_token as get_verification
|
|
31
|
+
from app.repositories.email_verification_repository import mark_used as mark_verification_used
|
|
32
|
+
from app.repositories.password_reset_repository import create as create_reset
|
|
33
|
+
from app.repositories.password_reset_repository import get_by_token as get_reset
|
|
34
|
+
from app.repositories.password_reset_repository import mark_used as mark_reset_used
|
|
35
|
+
from app.repositories.refresh_token_repository import find_active, revoke
|
|
36
|
+
from app.repositories.user_repository import get_by_email
|
|
37
|
+
from app.services.auth_service import hash_refresh_token, issue_tokens
|
|
38
|
+
from app.services.email_service import send_reset_email, send_verification_email
|
|
39
|
+
from app.services.social_auth_service import exchange_provider_code
|
|
40
|
+
from app.services.user_service import create_user
|
|
41
|
+
|
|
42
|
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/signup", response_model=APIResponse[UserOut], status_code=201)
|
|
46
|
+
@limiter.limit("10/minute")
|
|
47
|
+
async def signup(request: Request, payload: SignupRequest, session: AsyncSession = Depends(get_db)):
|
|
48
|
+
"""Create a user and persist a one-time email verification token."""
|
|
49
|
+
user = await create_user(session, payload)
|
|
50
|
+
raw_token = secrets.token_urlsafe(32)
|
|
51
|
+
await create_verification(
|
|
52
|
+
session,
|
|
53
|
+
EmailVerification(
|
|
54
|
+
user_id=user.id,
|
|
55
|
+
token_hash=hash_refresh_token(raw_token),
|
|
56
|
+
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
send_verification_email(user, raw_token)
|
|
60
|
+
return APIResponse(message="Account created", data=user, status_code=201)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/login", response_model=APIResponse[TokenResponse])
|
|
64
|
+
@limiter.limit("10/minute")
|
|
65
|
+
async def login(request: Request, payload: LoginRequest, session: AsyncSession = Depends(get_db)):
|
|
66
|
+
"""Verify credentials and issue access plus opaque refresh credentials."""
|
|
67
|
+
user = await get_by_email(session, str(payload.email))
|
|
68
|
+
if user is None or not user.is_active or not verify_password(payload.password, user.password_hash):
|
|
69
|
+
raise UnauthorizedError("Invalid credentials")
|
|
70
|
+
access, refresh, digest, expiry = issue_tokens(user.id)
|
|
71
|
+
session.add(RefreshToken(user_id=user.id, token_hash=digest, expires_at=expiry))
|
|
72
|
+
await session.commit()
|
|
73
|
+
return APIResponse(message="Login successful", data=TokenResponse(access_token=access, refresh_token=refresh))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/refresh", response_model=APIResponse[TokenResponse])
|
|
77
|
+
@limiter.limit("30/minute")
|
|
78
|
+
async def refresh(request: Request, payload: RefreshRequest, session: AsyncSession = Depends(get_db)):
|
|
79
|
+
"""Rotate a valid refresh token and revoke the predecessor."""
|
|
80
|
+
stored = await find_active(session, hash_refresh_token(payload.refresh_token))
|
|
81
|
+
if stored is None:
|
|
82
|
+
raise UnauthorizedError("Invalid refresh token")
|
|
83
|
+
user = await session.get(User, stored.user_id)
|
|
84
|
+
if user is None or not user.is_active:
|
|
85
|
+
raise UnauthorizedError("Inactive account")
|
|
86
|
+
access, raw_refresh, digest, expiry = issue_tokens(user.id)
|
|
87
|
+
await revoke(session, stored)
|
|
88
|
+
session.add(RefreshToken(user_id=user.id, token_hash=digest, expires_at=expiry))
|
|
89
|
+
await session.commit()
|
|
90
|
+
return APIResponse(message="Token refreshed", data=TokenResponse(access_token=access, refresh_token=raw_refresh))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.post("/logout", response_model=APIResponse[None])
|
|
94
|
+
@limiter.limit("30/minute")
|
|
95
|
+
async def logout(request: Request, payload: RefreshRequest, session: AsyncSession = Depends(get_db)):
|
|
96
|
+
"""Revoke the supplied refresh token without revealing whether it existed."""
|
|
97
|
+
stored = await find_active(session, hash_refresh_token(payload.refresh_token))
|
|
98
|
+
if stored is not None:
|
|
99
|
+
await revoke(session, stored)
|
|
100
|
+
return APIResponse(message="Logged out", data=None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/verify-email", response_model=APIResponse[None])
|
|
104
|
+
@limiter.limit("10/minute")
|
|
105
|
+
async def verify_email(request: Request, payload: EmailTokenRequest, session: AsyncSession = Depends(get_db)):
|
|
106
|
+
"""Consume a verification token and activate its account."""
|
|
107
|
+
stored = await get_verification(session, hash_refresh_token(payload.token))
|
|
108
|
+
if stored is None:
|
|
109
|
+
raise HTTPException(status_code=400, detail="Invalid verification token")
|
|
110
|
+
user = await session.get(User, stored.user_id)
|
|
111
|
+
if user is None:
|
|
112
|
+
raise HTTPException(status_code=400, detail="Invalid verification token")
|
|
113
|
+
user.is_verified = True
|
|
114
|
+
user.is_active = True
|
|
115
|
+
await mark_verification_used(session, stored)
|
|
116
|
+
return APIResponse(message="Email verified", data=None)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post("/forgot-password", response_model=APIResponse[None])
|
|
120
|
+
@limiter.limit("5/minute")
|
|
121
|
+
async def forgot_password(request: Request, payload: ForgotPassword, session: AsyncSession = Depends(get_db)):
|
|
122
|
+
"""Issue a reset token and send it without disclosing account existence."""
|
|
123
|
+
user = await get_by_email(session, str(payload.email))
|
|
124
|
+
if user is not None:
|
|
125
|
+
raw_token = secrets.token_urlsafe(32)
|
|
126
|
+
await create_reset(
|
|
127
|
+
session,
|
|
128
|
+
PasswordReset(
|
|
129
|
+
user_id=user.id,
|
|
130
|
+
token_hash=hash_refresh_token(raw_token),
|
|
131
|
+
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
send_reset_email(user, raw_token)
|
|
135
|
+
return APIResponse(message="If the account exists, a reset email was sent", data=None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/reset-password", response_model=APIResponse[None])
|
|
139
|
+
@limiter.limit("5/minute")
|
|
140
|
+
async def reset_password(request: Request, payload: ResetPassword, session: AsyncSession = Depends(get_db)):
|
|
141
|
+
"""Consume a reset token and replace the stored bcrypt password hash."""
|
|
142
|
+
stored = await get_reset(session, hash_refresh_token(payload.token))
|
|
143
|
+
if stored is None:
|
|
144
|
+
raise HTTPException(status_code=400, detail="Invalid reset token")
|
|
145
|
+
user = await session.get(User, stored.user_id)
|
|
146
|
+
if user is None:
|
|
147
|
+
raise HTTPException(status_code=400, detail="Invalid reset token")
|
|
148
|
+
user.password_hash = hash_password(payload.password)
|
|
149
|
+
await mark_reset_used(session, stored)
|
|
150
|
+
return APIResponse(message="Password reset", data=None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.post("/totp/setup", response_model=APIResponse[TotpSetupResponse])
|
|
154
|
+
@limiter.limit("10/minute")
|
|
155
|
+
async def setup_totp(
|
|
156
|
+
request: Request, user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db)
|
|
157
|
+
):
|
|
158
|
+
"""Generate encrypted TOTP provisioning data for the current user."""
|
|
159
|
+
secret = new_secret()
|
|
160
|
+
record = TotpSecret(user_id=user.id)
|
|
161
|
+
record.secret = secret
|
|
162
|
+
session.add(record)
|
|
163
|
+
await session.commit()
|
|
164
|
+
uri = __import__("pyotp").TOTP(secret).provisioning_uri(name=user.email, issuer_name=get_settings().app_name)
|
|
165
|
+
return APIResponse(message="TOTP configured", data=TotpSetupResponse(secret=secret, provisioning_uri=uri))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.post("/totp/verify", response_model=APIResponse[None])
|
|
169
|
+
@limiter.limit("10/minute")
|
|
170
|
+
async def verify_totp(
|
|
171
|
+
request: Request,
|
|
172
|
+
payload: TotpVerify,
|
|
173
|
+
user: User = Depends(get_current_active_user),
|
|
174
|
+
session: AsyncSession = Depends(get_db),
|
|
175
|
+
):
|
|
176
|
+
"""Verify the user's encrypted TOTP secret and enable the factor."""
|
|
177
|
+
record = await session.get(TotpSecret, user.id)
|
|
178
|
+
if record is None or not verify_code(record.secret, payload.code):
|
|
179
|
+
raise HTTPException(status_code=400, detail="Invalid TOTP code")
|
|
180
|
+
return APIResponse(message="TOTP verified", data=None)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.get("/google")
|
|
184
|
+
@limiter.limit("30/minute")
|
|
185
|
+
async def google_login(request: Request):
|
|
186
|
+
"""Redirect to Google's OAuth authorization endpoint."""
|
|
187
|
+
settings = get_settings()
|
|
188
|
+
params = urlencode(
|
|
189
|
+
{
|
|
190
|
+
"client_id": settings.google_client_id,
|
|
191
|
+
"redirect_uri": "/api/v1/auth/google/callback",
|
|
192
|
+
"response_type": "code",
|
|
193
|
+
"scope": "openid email profile",
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.get("/google/callback", response_model=APIResponse[TokenResponse])
|
|
200
|
+
@limiter.limit("30/minute")
|
|
201
|
+
async def google_callback(request: Request, code: str, session: AsyncSession = Depends(get_db)):
|
|
202
|
+
"""Exchange Google's code and provision or authenticate the user."""
|
|
203
|
+
return await _social_login("google", code, session)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.get("/facebook")
|
|
207
|
+
@limiter.limit("30/minute")
|
|
208
|
+
async def facebook_login(request: Request):
|
|
209
|
+
"""Redirect to Facebook's OAuth authorization endpoint."""
|
|
210
|
+
settings = get_settings()
|
|
211
|
+
params = urlencode(
|
|
212
|
+
{
|
|
213
|
+
"client_id": settings.facebook_client_id,
|
|
214
|
+
"redirect_uri": "/api/v1/auth/facebook/callback",
|
|
215
|
+
"response_type": "code",
|
|
216
|
+
"scope": "email,public_profile",
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
return RedirectResponse(f"https://www.facebook.com/v19.0/dialog/oauth?{params}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.get("/facebook/callback", response_model=APIResponse[TokenResponse])
|
|
223
|
+
@limiter.limit("30/minute")
|
|
224
|
+
async def facebook_callback(request: Request, code: str, session: AsyncSession = Depends(get_db)):
|
|
225
|
+
"""Exchange Facebook's code and provision or authenticate the user."""
|
|
226
|
+
return await _social_login("facebook", code, session)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _social_login(provider: str, code: str, session: AsyncSession):
|
|
230
|
+
"""Normalize a provider profile and issue the same token pair as password login."""
|
|
231
|
+
profile = await exchange_provider_code(provider, code)
|
|
232
|
+
user = await get_by_email(session, profile["email"])
|
|
233
|
+
if user is None:
|
|
234
|
+
user = User(
|
|
235
|
+
email=profile["email"],
|
|
236
|
+
full_name=profile["full_name"],
|
|
237
|
+
password_hash=hash_password(secrets.token_urlsafe(32)),
|
|
238
|
+
is_verified=True,
|
|
239
|
+
)
|
|
240
|
+
session.add(user)
|
|
241
|
+
await session.flush()
|
|
242
|
+
access, refresh_token, digest, expiry = issue_tokens(user.id)
|
|
243
|
+
session.add(RefreshToken(user_id=user.id, token_hash=digest, expires_at=expiry))
|
|
244
|
+
await session.commit()
|
|
245
|
+
return APIResponse(
|
|
246
|
+
message=f"{provider.title()} login successful",
|
|
247
|
+
data=TokenResponse(access_token=access, refresh_token=refresh_token),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@router.get("/me", response_model=APIResponse[UserOut])
|
|
252
|
+
@limiter.limit("60/minute")
|
|
253
|
+
async def me(request: Request, user: User = Depends(get_current_active_user)):
|
|
254
|
+
"""Return the authenticated user's safe profile."""
|
|
255
|
+
return APIResponse(data=user)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Operational dashboard endpoint placeholder for aggregated metrics."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Request
|
|
4
|
+
from sqlalchemy import func, select
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from app.api.deps.auth import get_current_admin
|
|
8
|
+
from app.core.middleware.rate_limit import limiter
|
|
9
|
+
from app.db.session import get_db
|
|
10
|
+
from app.models.domain.product import Product
|
|
11
|
+
from app.models.domain.user import User
|
|
12
|
+
from app.models.schemas.response import APIResponse
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/stats", response_model=APIResponse[dict[str, int]])
|
|
18
|
+
@limiter.limit("60/minute")
|
|
19
|
+
async def stats(request: Request, _: User = Depends(get_current_admin), session: AsyncSession = Depends(get_db)):
|
|
20
|
+
"""Return current user and product counts for administrators."""
|
|
21
|
+
users = await session.scalar(select(func.count()).select_from(User))
|
|
22
|
+
products = await session.scalar(select(func.count()).select_from(Product))
|
|
23
|
+
return APIResponse(data={"users": users or 0, "products": products or 0, "active_sessions": 0})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Product CRUD endpoints with bounded filtering, sorting, and pagination."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query, Request
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.api.deps.auth import get_current_active_user
|
|
11
|
+
from app.api.deps.pagination import pagination
|
|
12
|
+
from app.core.exceptions.custom_exceptions import NotFoundError, UnauthorizedError
|
|
13
|
+
from app.core.middleware.rate_limit import limiter
|
|
14
|
+
from app.db.session import get_db
|
|
15
|
+
from app.models.domain.product import Product
|
|
16
|
+
from app.models.domain.user import User
|
|
17
|
+
from app.models.schemas.product import ProductCreate, ProductOut, ProductPage
|
|
18
|
+
from app.models.schemas.response import APIResponse
|
|
19
|
+
from app.repositories.product_repository import ProductRepository
|
|
20
|
+
from app.services.product_service import create_product, update_product
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/products", tags=["products"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("", response_model=APIResponse[ProductOut], status_code=201)
|
|
26
|
+
@limiter.limit("60/minute")
|
|
27
|
+
async def create(
|
|
28
|
+
request: Request,
|
|
29
|
+
payload: ProductCreate,
|
|
30
|
+
user: User = Depends(get_current_active_user),
|
|
31
|
+
session: AsyncSession = Depends(get_db),
|
|
32
|
+
):
|
|
33
|
+
"""Create a product owned by the authenticated user."""
|
|
34
|
+
product = await create_product(session, payload)
|
|
35
|
+
product.owner_id = user.id
|
|
36
|
+
await session.commit()
|
|
37
|
+
return APIResponse(message="Product created", data=product, status_code=201)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("", response_model=APIResponse[ProductPage])
|
|
41
|
+
@limiter.limit("120/minute")
|
|
42
|
+
async def list_products(
|
|
43
|
+
request: Request,
|
|
44
|
+
page: tuple[int, int] = Depends(pagination),
|
|
45
|
+
category: str | None = None,
|
|
46
|
+
min_price: Decimal | None = Query(None, ge=0),
|
|
47
|
+
max_price: Decimal | None = Query(None, ge=0),
|
|
48
|
+
sort_by: str = Query("created_at", pattern="^(name|price|created_at)$"),
|
|
49
|
+
session: AsyncSession = Depends(get_db),
|
|
50
|
+
):
|
|
51
|
+
"""Return products and deterministic pagination metadata."""
|
|
52
|
+
offset, limit = page
|
|
53
|
+
items = await ProductRepository(session).get_all(offset, limit, category, min_price, max_price, sort_by)
|
|
54
|
+
total = await session.scalar(select(func.count()).select_from(Product))
|
|
55
|
+
return APIResponse(data={"items": items, "total_count": total or 0, "page": offset // limit + 1, "limit": limit})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/{product_id}", response_model=APIResponse[ProductOut])
|
|
59
|
+
@limiter.limit("120/minute")
|
|
60
|
+
async def get_product(request: Request, product_id: UUID, session: AsyncSession = Depends(get_db)):
|
|
61
|
+
"""Return one product by UUID."""
|
|
62
|
+
product = await session.get(Product, product_id)
|
|
63
|
+
if product is None:
|
|
64
|
+
raise NotFoundError("Product not found")
|
|
65
|
+
return APIResponse(data=product)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.put("/{product_id}", response_model=APIResponse[ProductOut])
|
|
69
|
+
@limiter.limit("60/minute")
|
|
70
|
+
async def update_product_endpoint(
|
|
71
|
+
request: Request,
|
|
72
|
+
product_id: UUID,
|
|
73
|
+
payload: ProductCreate,
|
|
74
|
+
user: User = Depends(get_current_active_user),
|
|
75
|
+
session: AsyncSession = Depends(get_db),
|
|
76
|
+
):
|
|
77
|
+
"""Update a product when the caller owns it or is an administrator."""
|
|
78
|
+
product = await session.get(Product, product_id)
|
|
79
|
+
if product is None:
|
|
80
|
+
raise NotFoundError("Product not found")
|
|
81
|
+
if product.owner_id not in {None, user.id} and not user.is_admin:
|
|
82
|
+
raise UnauthorizedError("Product ownership required")
|
|
83
|
+
return APIResponse(data=await update_product(session, product, payload), message="Product updated")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.delete("/{product_id}", response_model=APIResponse[None])
|
|
87
|
+
@limiter.limit("60/minute")
|
|
88
|
+
async def delete_product(
|
|
89
|
+
request: Request,
|
|
90
|
+
product_id: UUID,
|
|
91
|
+
user: User = Depends(get_current_active_user),
|
|
92
|
+
session: AsyncSession = Depends(get_db),
|
|
93
|
+
):
|
|
94
|
+
"""Delete a product when the caller owns it or is an administrator."""
|
|
95
|
+
product = await session.get(Product, product_id)
|
|
96
|
+
if product is None:
|
|
97
|
+
raise NotFoundError("Product not found")
|
|
98
|
+
if product.owner_id not in {None, user.id} and not user.is_admin:
|
|
99
|
+
raise UnauthorizedError("Product ownership required")
|
|
100
|
+
await session.delete(product)
|
|
101
|
+
await session.commit()
|
|
102
|
+
return APIResponse(message="Product deleted", data=None)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Authenticated profile and administrator user-management endpoints."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, Request
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.api.deps.auth import get_current_active_user, get_current_admin
|
|
9
|
+
from app.api.deps.pagination import pagination
|
|
10
|
+
from app.core.middleware.rate_limit import limiter
|
|
11
|
+
from app.db.session import get_db
|
|
12
|
+
from app.models.domain.user import User
|
|
13
|
+
from app.models.schemas.response import APIResponse
|
|
14
|
+
from app.models.schemas.user import AdminUserUpdate, UserOut, UserUpdate
|
|
15
|
+
from app.repositories import user_repository
|
|
16
|
+
from app.services.user_service import update_user
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.patch("/me", response_model=APIResponse[UserOut])
|
|
22
|
+
@limiter.limit("60/minute")
|
|
23
|
+
async def update_me(
|
|
24
|
+
request: Request,
|
|
25
|
+
payload: UserUpdate,
|
|
26
|
+
user: User = Depends(get_current_active_user),
|
|
27
|
+
session: AsyncSession = Depends(get_db),
|
|
28
|
+
):
|
|
29
|
+
"""Update allowed profile fields for the current account."""
|
|
30
|
+
return APIResponse(data=await update_user(session, user, payload), message="Profile updated")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("", response_model=APIResponse[list[UserOut]])
|
|
34
|
+
@limiter.limit("60/minute")
|
|
35
|
+
async def list_users(
|
|
36
|
+
request: Request,
|
|
37
|
+
page: tuple[int, int] = Depends(pagination),
|
|
38
|
+
_: User = Depends(get_current_admin),
|
|
39
|
+
session: AsyncSession = Depends(get_db),
|
|
40
|
+
):
|
|
41
|
+
"""List users for administrators with bounded pagination."""
|
|
42
|
+
return APIResponse(data=await user_repository.get_all(session, *page))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/{user_id}", response_model=APIResponse[UserOut])
|
|
46
|
+
@limiter.limit("60/minute")
|
|
47
|
+
async def get_user(
|
|
48
|
+
request: Request, user_id: UUID, _: User = Depends(get_current_admin), session: AsyncSession = Depends(get_db)
|
|
49
|
+
):
|
|
50
|
+
"""Return one user to an administrator."""
|
|
51
|
+
user = await user_repository.get_by_id(session, user_id)
|
|
52
|
+
if user is None:
|
|
53
|
+
from app.core.exceptions.custom_exceptions import NotFoundError
|
|
54
|
+
|
|
55
|
+
raise NotFoundError("User not found")
|
|
56
|
+
return APIResponse(data=user)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.patch("/{user_id}", response_model=APIResponse[UserOut])
|
|
60
|
+
@limiter.limit("30/minute")
|
|
61
|
+
async def update_user_admin(
|
|
62
|
+
request: Request,
|
|
63
|
+
user_id: UUID,
|
|
64
|
+
payload: AdminUserUpdate,
|
|
65
|
+
_: User = Depends(get_current_admin),
|
|
66
|
+
session: AsyncSession = Depends(get_db),
|
|
67
|
+
):
|
|
68
|
+
"""Update profile and role fields as an administrator."""
|
|
69
|
+
user = await user_repository.get_by_id(session, user_id)
|
|
70
|
+
if user is None:
|
|
71
|
+
from app.core.exceptions.custom_exceptions import NotFoundError
|
|
72
|
+
|
|
73
|
+
raise NotFoundError("User not found")
|
|
74
|
+
return APIResponse(data=await user_repository.update(session, user, payload.model_dump(exclude_none=True)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.delete("/{user_id}", response_model=APIResponse[None])
|
|
78
|
+
@limiter.limit("30/minute")
|
|
79
|
+
async def delete_user(
|
|
80
|
+
request: Request, user_id: UUID, _: User = Depends(get_current_admin), session: AsyncSession = Depends(get_db)
|
|
81
|
+
):
|
|
82
|
+
"""Hard-delete a user and dependent records as an administrator."""
|
|
83
|
+
user = await user_repository.get_by_id(session, user_id)
|
|
84
|
+
if user is None:
|
|
85
|
+
from app.core.exceptions.custom_exceptions import NotFoundError
|
|
86
|
+
|
|
87
|
+
raise NotFoundError("User not found")
|
|
88
|
+
await user_repository.delete(session, user)
|
|
89
|
+
return APIResponse(message="User deleted", data=None)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Router registration namespace."""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Central versioned router registry."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from app.api.v1.endpoints import auth, dashboard, products, users
|
|
6
|
+
|
|
7
|
+
api_router = APIRouter()
|
|
8
|
+
api_router.include_router(auth.router)
|
|
9
|
+
api_router.include_router(users.router)
|
|
10
|
+
api_router.include_router(products.router)
|
|
11
|
+
api_router.include_router(dashboard.router)
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Cross-cutting configuration, security, middleware, and exception modules."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration and database setup namespace."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Async SQLAlchemy engine and session factory.
|
|
2
|
+
|
|
3
|
+
The engine is created lazily at import time and sessions are request-scoped by
|
|
4
|
+
dependency. Pool values should be tuned to deployment capacity, not guessed in
|
|
5
|
+
individual endpoints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
9
|
+
|
|
10
|
+
from app.core.config.settings import get_settings
|
|
11
|
+
|
|
12
|
+
engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
|
13
|
+
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Validated environment configuration for every runtime component.
|
|
2
|
+
|
|
3
|
+
Pydantic Settings reads environment variables and an optional ``.env`` file,
|
|
4
|
+
making deployments twelve-factor friendly. Change defaults here only for safe
|
|
5
|
+
local development; production secrets must come from a secret manager.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
|
|
10
|
+
from pydantic import AliasChoices, Field, SecretStr, field_validator
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Settings(BaseSettings):
|
|
15
|
+
"""Application settings with validation and conservative security defaults."""
|
|
16
|
+
|
|
17
|
+
app_name: str = Field(default="Hinbert FastAPI", validation_alias=AliasChoices("APP_NAME", "HINBERT_APP_NAME"))
|
|
18
|
+
api_prefix: str = "/api/v1"
|
|
19
|
+
environment: str = Field(default="development", validation_alias=AliasChoices("ENVIRONMENT", "HINBERT_ENVIRONMENT"))
|
|
20
|
+
database_url: str = Field(
|
|
21
|
+
default="postgresql+asyncpg://postgres:postgres@localhost:5432/app",
|
|
22
|
+
validation_alias=AliasChoices("DATABASE_URL", "HINBERT_DATABASE_URL"),
|
|
23
|
+
)
|
|
24
|
+
redis_url: str = Field(
|
|
25
|
+
default="redis://localhost:6379/0", validation_alias=AliasChoices("REDIS_URL", "HINBERT_REDIS_URL")
|
|
26
|
+
)
|
|
27
|
+
debug: bool = Field(default=False, validation_alias=AliasChoices("DEBUG", "HINBERT_DEBUG"))
|
|
28
|
+
jwt_secret_key: SecretStr = Field(
|
|
29
|
+
default=SecretStr("change-me-in-production"),
|
|
30
|
+
validation_alias=AliasChoices("SECRET_KEY", "HINBERT_JWT_SECRET_KEY"),
|
|
31
|
+
)
|
|
32
|
+
jwt_algorithm: str = Field(default="HS256", validation_alias=AliasChoices("ALGORITHM", "HINBERT_JWT_ALGORITHM"))
|
|
33
|
+
access_token_minutes: int = Field(
|
|
34
|
+
default=15, validation_alias=AliasChoices("ACCESS_TOKEN_EXPIRE_MINUTES", "HINBERT_ACCESS_TOKEN_MINUTES")
|
|
35
|
+
)
|
|
36
|
+
refresh_token_days: int = Field(
|
|
37
|
+
default=30, validation_alias=AliasChoices("REFRESH_TOKEN_EXPIRE_DAYS", "HINBERT_REFRESH_TOKEN_DAYS")
|
|
38
|
+
)
|
|
39
|
+
cors_origins: list[str] = Field(
|
|
40
|
+
default_factory=lambda: ["http://localhost:3000"],
|
|
41
|
+
validation_alias=AliasChoices("BACKEND_CORS_ORIGINS", "HINBERT_CORS_ORIGINS"),
|
|
42
|
+
)
|
|
43
|
+
smtp_host: str = Field(default="localhost", validation_alias=AliasChoices("SMTP_HOST", "HINBERT_SMTP_HOST"))
|
|
44
|
+
smtp_port: int = Field(default=587, validation_alias=AliasChoices("SMTP_PORT", "HINBERT_SMTP_PORT"))
|
|
45
|
+
smtp_username: str = Field(default="", validation_alias=AliasChoices("SMTP_USER", "HINBERT_SMTP_USERNAME"))
|
|
46
|
+
smtp_password: SecretStr = Field(
|
|
47
|
+
default=SecretStr(""), validation_alias=AliasChoices("SMTP_PASSWORD", "HINBERT_SMTP_PASSWORD")
|
|
48
|
+
)
|
|
49
|
+
smtp_from: str = Field(
|
|
50
|
+
default="no-reply@example.com", validation_alias=AliasChoices("EMAIL_FROM", "HINBERT_SMTP_FROM")
|
|
51
|
+
)
|
|
52
|
+
google_client_id: str = Field(
|
|
53
|
+
default="", validation_alias=AliasChoices("GOOGLE_CLIENT_ID", "HINBERT_GOOGLE_CLIENT_ID")
|
|
54
|
+
)
|
|
55
|
+
google_client_secret: SecretStr = Field(
|
|
56
|
+
default=SecretStr(""), validation_alias=AliasChoices("GOOGLE_CLIENT_SECRET", "HINBERT_GOOGLE_CLIENT_SECRET")
|
|
57
|
+
)
|
|
58
|
+
facebook_client_id: str = Field(
|
|
59
|
+
default="", validation_alias=AliasChoices("FACEBOOK_CLIENT_ID", "HINBERT_FACEBOOK_CLIENT_ID")
|
|
60
|
+
)
|
|
61
|
+
facebook_client_secret: SecretStr = Field(
|
|
62
|
+
default=SecretStr(""), validation_alias=AliasChoices("FACEBOOK_CLIENT_SECRET", "HINBERT_FACEBOOK_CLIENT_SECRET")
|
|
63
|
+
)
|
|
64
|
+
rate_limit: str = Field(
|
|
65
|
+
default="100/minute", validation_alias=AliasChoices("RATE_LIMIT_PER_MINUTE", "HINBERT_RATE_LIMIT")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
model_config = SettingsConfigDict(env_file=".env", env_prefix="", extra="ignore")
|
|
69
|
+
|
|
70
|
+
@field_validator("jwt_secret_key")
|
|
71
|
+
@classmethod
|
|
72
|
+
def reject_weak_production_secret(cls, value: SecretStr) -> SecretStr:
|
|
73
|
+
"""Prevent the documented development secret from reaching production."""
|
|
74
|
+
secret = value.get_secret_value()
|
|
75
|
+
if secret == "change-me-in-production" or len(secret) < 32:
|
|
76
|
+
raise ValueError("SECRET_KEY must be at least 32 characters and must not use the default value")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@lru_cache
|
|
81
|
+
def get_settings() -> Settings:
|
|
82
|
+
"""Return one cached, validated settings object per process."""
|
|
83
|
+
return Settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public application exception types."""
|