teoat commited on
Commit
b0fbf67
·
verified ·
1 Parent(s): c98cc5a

Upload app/routers/auth.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app/routers/auth.py +3 -364
app/routers/auth.py CHANGED
@@ -1,365 +1,4 @@
1
- from datetime import datetime
 
 
2
 
3
- import pyotp
4
- from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
5
- from pydantic import BaseModel, Field
6
-
7
- from app.services.infrastructure.auth_service import auth_service
8
- from app.services.infrastructure.storage.database_service import db_service
9
- from core.database import User
10
- from core.logging import logger
11
-
12
- router = APIRouter()
13
-
14
-
15
- # Authentication models
16
- class LoginRequest(BaseModel):
17
- username: str = Field( min_length=1, max_length=50)
18
- password: str = Field( min_length=8)
19
- mfa_code: str | None = None
20
-
21
-
22
- class TokenResponse(BaseModel):
23
- access_token: str
24
- refresh_token: str
25
- token_type: str = "bearer"
26
- expires_in: int = 1800 # 30 minutes
27
-
28
-
29
- class UserCreateRequest(BaseModel):
30
- username: str = Field( min_length=3, max_length=50)
31
- email: str = Field( pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
32
- full_name: str = Field( min_length=1, max_length=100)
33
- role: str = Field( pattern=r"^(analyst|senior_analyst|investigator|manager|admin)$")
34
-
35
-
36
- class RegisterRequest(BaseModel):
37
- username: str = Field( min_length=3, max_length=50)
38
- email: str = Field( pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
39
- password: str = Field( min_length=8, max_length=128)
40
- full_name: str = Field( min_length=1, max_length=100)
41
- role: str | None = "ANALYST" # Default role
42
-
43
-
44
- class UserProfileResponse(BaseModel):
45
- id: str
46
- username: str
47
- email: str
48
- full_name: str
49
- role: str
50
- is_active: bool
51
- mfa_enabled: bool
52
- created_at: datetime
53
- last_login: datetime | None
54
-
55
-
56
- # ===== AUTHENTICATION ENDPOINTS =====
57
-
58
-
59
- class RegisterResponse(BaseModel):
60
- user_id: str
61
- username: str
62
- email: str
63
- message: str
64
- created_at: datetime
65
-
66
-
67
- @router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
68
- async def register(user_data: RegisterRequest):
69
- """
70
- Register a new user with password strength validation
71
- """
72
- try:
73
- # Validate password strength
74
- password_errors = auth_service.validate_password_strength(user_data.password)
75
- if password_errors:
76
- raise HTTPException(
77
- status_code=status.HTTP_400_BAD_REQUEST,
78
- detail={
79
- "message": "Password does not meet security requirements",
80
- "errors": password_errors,
81
- },
82
- )
83
-
84
- # Check if username already exists
85
- existing_user = auth_service.get_user_by_username(user_data.username)
86
- if existing_user:
87
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already exists")
88
-
89
- # Check if email already exists
90
- existing_email = auth_service.get_user_by_email(user_data.email)
91
- if existing_email:
92
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
93
-
94
- # Create user
95
- new_user = auth_service.create_user(user_data)
96
-
97
- logger.info(f"New user registered: {new_user.username}")
98
-
99
- return {
100
- "id": new_user.id,
101
- "username": new_user.username,
102
- "email": new_user.email,
103
- "full_name": new_user.full_name,
104
- "role": new_user.role,
105
- "message": "User registered successfully",
106
- }
107
-
108
- except HTTPException:
109
- raise
110
- except Exception as e:
111
- logger.error(f"Registration error: {e!s}")
112
- raise HTTPException(
113
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
114
- detail="An internal error occurred during registration.",
115
- )
116
-
117
-
118
- @router.post("/login", response_model=UserProfileResponse)
119
- async def login(login_data: LoginRequest, request: Request, response: Response):
120
- """
121
- Authenticate user and set HttpOnly cookies.
122
- """
123
- try:
124
- user = auth_service.authenticate_user(login_data.username, login_data.password)
125
- if not user:
126
- logger.warning(
127
- f"Failed login attempt for username: {login_data.username} from IP: {request.client.host if request.client else 'unknown'}"
128
- )
129
- raise HTTPException(
130
- status_code=status.HTTP_401_UNAUTHORIZED,
131
- detail="Invalid username or password",
132
- )
133
-
134
- # CHECK MFA
135
- if user.mfa_enabled:
136
- # If MFA enabled and no code provided, return 403 to trigger frontend prompt
137
- if not login_data.mfa_code:
138
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="MFA code required")
139
-
140
- if not user.mfa_secret:
141
- logger.error(f"User {user.username} has MFA enabled but no secret")
142
- raise HTTPException(status_code=500, detail="MFA configuration error")
143
-
144
- totp = pyotp.TOTP(user.mfa_secret)
145
- if not totp.verify(login_data.mfa_code):
146
- logger.warning(f"Invalid MFA code for user {user.username}")
147
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MFA code")
148
-
149
- logger.info(
150
- f"Successful login for user: {user.username} (ID: {user.id}) from IP: {request.client.host if request.client else 'unknown'}"
151
- )
152
-
153
- # Track user journey
154
- try:
155
- from app.services.business.user_journey_tracker import user_journey_tracker
156
-
157
- user_journey_tracker.track_event(
158
- user_id=user.id,
159
- event_type="login",
160
- metadata={"role": user.role, "mfa": user.mfa_enabled},
161
- )
162
- except Exception as e:
163
- logger.warning(f"Failed to track user journey event: {e}")
164
-
165
- # Create tokens
166
- access_token = auth_service.create_access_token(
167
- {
168
- "sub": user.id,
169
- "username": user.username,
170
- "role": user.role,
171
- "mfa_verified": user.mfa_enabled,
172
- }
173
- )
174
- refresh_token = auth_service.create_refresh_token(user.id)
175
-
176
- # Set HttpOnly Cookies
177
- # Secure=True in production (HTTPS), False in dev if needed
178
- secure_cookie = True
179
-
180
- response.set_cookie(
181
- key="access_token",
182
- value=access_token,
183
- httponly=True,
184
- secure=secure_cookie,
185
- samesite="strict",
186
- max_age=1800, # 30 minutes
187
- )
188
-
189
- response.set_cookie(
190
- key="refresh_token",
191
- value=refresh_token,
192
- httponly=True,
193
- secure=secure_cookie,
194
- samesite="strict",
195
- path="/api/v1/auth/refresh",
196
- max_age=7 * 24 * 60 * 60, # 7 days
197
- )
198
-
199
- return {
200
- "id": user.id,
201
- "username": user.username,
202
- "email": user.email,
203
- "full_name": user.full_name,
204
- "role": user.role,
205
- "is_active": user.is_active,
206
- "mfa_enabled": user.mfa_enabled,
207
- "created_at": user.created_at,
208
- "last_login": user.last_login,
209
- }
210
-
211
- except HTTPException:
212
- raise
213
- except Exception as e:
214
- logger.error(f"Login error: {e!s}")
215
- raise HTTPException(status_code=500, detail="Internal login error")
216
-
217
-
218
- class MFAVerifyRequest(BaseModel):
219
- code: str
220
-
221
-
222
- class MFASetupResponse(BaseModel):
223
- secret: str
224
- otpauth_url: str
225
-
226
-
227
- @router.get("/mfa/setup", response_model=MFASetupResponse)
228
- async def mfa_setup(current_user: User = Depends(auth_service.get_current_user)):
229
- """Generate MFA secret and QR code URI for setup"""
230
- if current_user.mfa_enabled:
231
- raise HTTPException(status_code=400, detail="MFA is already enabled")
232
-
233
- # Generate secret
234
- secret = pyotp.random_base32()
235
-
236
- # Save secret to DB (but don't enable yet)
237
- # We must fetch a fresh user instance attached to a session to update
238
- with db_service.get_db() as db:
239
- user = db.query(User).filter(User.id == current_user.id).first()
240
- if not user:
241
- raise HTTPException(status_code=404, detail="User not found")
242
-
243
- user.mfa_secret = secret
244
- db.commit()
245
-
246
- # Generate Provisioning URI
247
- uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.email, issuer_name="Zenith Fraud Platform")
248
-
249
- return {"secret": secret, "otpauth_url": uri}
250
-
251
-
252
- class MFAVerifyResponse(BaseModel):
253
- verified: bool
254
- message: str
255
-
256
-
257
- @router.post("/mfa/verify", response_model=MFAVerifyResponse)
258
- async def mfa_verify(
259
- verify_data: MFAVerifyRequest,
260
- current_user: User = Depends(auth_service.get_current_user),
261
- ):
262
- """Verify MFA code and enable MFA for the account"""
263
- if current_user.mfa_enabled:
264
- return {"message": "MFA is already enabled"}
265
-
266
- # We need the secret from the DB
267
- # Fetch fresh user
268
- with db_service.get_db() as db:
269
- user = db.query(User).filter(User.id == current_user.id).first()
270
- if not user or not user.mfa_secret:
271
- raise HTTPException(status_code=400, detail="MFA setup not initiated (no secret found)")
272
-
273
- totp = pyotp.TOTP(user.mfa_secret)
274
- if not totp.verify(verify_data.code):
275
- raise HTTPException(status_code=400, detail="Invalid code")
276
-
277
- # Enable MFA
278
- user.mfa_enabled = True
279
- db.commit()
280
-
281
- logger.info(f"MFA enabled for user {current_user.username}")
282
- return {"message": "MFA enabled successfully"}
283
-
284
-
285
- @router.post("/refresh")
286
- async def refresh_token(request: Request, response: Response):
287
- """
288
- Refresh access token using HttpOnly cookie.
289
- """
290
- refresh_token = request.cookies.get("refresh_token")
291
- if not refresh_token:
292
- raise HTTPException(status_code=401, detail="Refresh cookie missing")
293
-
294
- try:
295
- # Verify refresh token
296
- payload = auth_service.decode_token(refresh_token)
297
- if payload.get("type") != "refresh":
298
- raise HTTPException(status_code=401, detail="Invalid token type")
299
-
300
- user_id = payload.get("sub")
301
-
302
- # Determine claims
303
- user = auth_service.get_user(user_id) if hasattr(auth_service, "get_user") else None
304
-
305
- if not user:
306
- # Security fix: Do not issue tokens for non-existent/inactive users
307
- # Previously this fell back to "unknown" user which allowed access after deletion
308
- logger.warning(f"Refresh attempt for non-existent user: {user_id}")
309
- raise HTTPException(status_code=401, detail="User no longer exists or is inactive")
310
-
311
- claims = {
312
- "sub": user_id,
313
- "username": user.username,
314
- "role": user.role,
315
- "mfa_verified": user.mfa_enabled,
316
- }
317
-
318
- new_access_token = auth_service.create_access_token(claims)
319
-
320
- # Set new access token cookie
321
- response.set_cookie(
322
- key="access_token",
323
- value=new_access_token,
324
- httponly=True,
325
- secure=True,
326
- samesite="strict",
327
- max_age=1800, # 30 minutes
328
- )
329
-
330
- return {"message": "Token refreshed"}
331
-
332
- except Exception as e:
333
- logger.warning(f"Refresh failed: {e}")
334
- # Clear cookies on failure
335
- response.delete_cookie("access_token")
336
- response.delete_cookie("refresh_token", path="/api/v1/auth/refresh")
337
- raise HTTPException(status_code=401, detail="Invalid refresh token")
338
-
339
-
340
- @router.post("/logout")
341
- async def logout(response: Response):
342
- """
343
- Logout user by clearing cookies.
344
- """
345
- response.delete_cookie("access_token")
346
- response.delete_cookie("refresh_token", path="/api/v1/auth/refresh")
347
- return {"message": "Logged out successfully"}
348
-
349
-
350
- @router.get("/me", response_model=UserProfileResponse)
351
- async def get_current_user_profile(
352
- current_user: User = Depends(auth_service.get_current_user),
353
- ):
354
- """Get current user profile"""
355
- return {
356
- "id": current_user.id,
357
- "username": current_user.username,
358
- "email": current_user.email,
359
- "full_name": current_user.full_name,
360
- "role": current_user.role,
361
- "is_active": current_user.is_active,
362
- "mfa_enabled": current_user.mfa_enabled,
363
- "created_at": current_user.created_at,
364
- "last_login": current_user.last_login,
365
- }
 
1
+ # SHIM: Redirects to new module location
2
+ # This file is maintained for backward compatibility.
3
+ # Please import from app.modules.auth.router instead.
4