6
6
from fastapi .responses import RedirectResponse
7
7
from pydantic import BaseModel , EmailStr , ConfigDict
8
8
from sqlmodel import Session , select
9
- from utils .models import User , UserPassword
9
+ from utils .models import User , UserPassword , DataIntegrityError
10
10
from utils .auth import (
11
11
get_session ,
12
12
get_user_from_reset_token ,
18
18
create_access_token ,
19
19
create_refresh_token ,
20
20
validate_token ,
21
- send_reset_email
21
+ send_reset_email ,
22
+ send_email_update_confirmation ,
23
+ get_user_from_email_update_token ,
24
+ get_authenticated_user
22
25
)
23
26
24
27
logger = getLogger ("uvicorn.error" )
25
28
26
29
router = APIRouter (prefix = "/auth" , tags = ["auth" ])
27
30
31
+ # --- Custom Exceptions ---
32
+
33
+
34
+ class EmailAlreadyRegisteredError (HTTPException ):
35
+ def __init__ (self ):
36
+ super ().__init__ (
37
+ status_code = 409 ,
38
+ detail = "This email is already registered"
39
+ )
40
+
41
+
42
+ class InvalidCredentialsError (HTTPException ):
43
+ def __init__ (self ):
44
+ super ().__init__ (
45
+ status_code = 401 ,
46
+ detail = "Invalid credentials"
47
+ )
48
+
49
+
50
+ class InvalidResetTokenError (HTTPException ):
51
+ def __init__ (self ):
52
+ super ().__init__ (
53
+ status_code = 401 ,
54
+ detail = "Invalid or expired password reset token; please request a new one"
55
+ )
56
+
57
+
58
+ class InvalidEmailUpdateTokenError (HTTPException ):
59
+ def __init__ (self ):
60
+ super ().__init__ (
61
+ status_code = 401 ,
62
+ detail = "Invalid or expired email update token; please request a new one"
63
+ )
64
+
28
65
29
66
# --- Server Request and Response Models ---
30
67
@@ -105,6 +142,17 @@ async def as_form(
105
142
)
106
143
107
144
145
+ class UpdateEmail (BaseModel ):
146
+ new_email : EmailStr
147
+
148
+ @classmethod
149
+ async def as_form (
150
+ cls ,
151
+ new_email : EmailStr = Form (...)
152
+ ):
153
+ return cls (new_email = new_email )
154
+
155
+
108
156
# --- DB Request and Response Models ---
109
157
110
158
@@ -133,7 +181,7 @@ async def register(
133
181
User .email == user .email )).first ()
134
182
135
183
if db_user :
136
- raise HTTPException ( status_code = 400 , detail = "Email already registered" )
184
+ raise EmailAlreadyRegisteredError ( )
137
185
138
186
# Hash the password
139
187
hashed_password = get_password_hash (user .password )
@@ -150,9 +198,20 @@ async def register(
150
198
refresh_token = create_refresh_token (data = {"sub" : db_user .email })
151
199
# Set cookie
152
200
response = RedirectResponse (url = "/" , status_code = 303 )
153
- response .set_cookie (key = "access_token" , value = access_token , httponly = True )
154
- response .set_cookie (key = "refresh_token" ,
155
- value = refresh_token , httponly = True )
201
+ response .set_cookie (
202
+ key = "access_token" ,
203
+ value = access_token ,
204
+ httponly = True ,
205
+ secure = True ,
206
+ samesite = "strict"
207
+ )
208
+ response .set_cookie (
209
+ key = "refresh_token" ,
210
+ value = refresh_token ,
211
+ httponly = True ,
212
+ secure = True ,
213
+ samesite = "strict"
214
+ )
156
215
157
216
return response
158
217
@@ -167,7 +226,7 @@ async def login(
167
226
User .email == user .email )).first ()
168
227
169
228
if not db_user or not db_user .password or not verify_password (user .password , db_user .password .hashed_password ):
170
- raise HTTPException ( status_code = 400 , detail = "Invalid credentials" )
229
+ raise InvalidCredentialsError ( )
171
230
172
231
# Create access token
173
232
access_token = create_access_token (
@@ -296,7 +355,7 @@ async def reset_password(
296
355
user .email , user .token , session )
297
356
298
357
if not authorized_user or not reset_token :
299
- raise HTTPException ( status_code = 400 , detail = "Invalid or expired token" )
358
+ raise InvalidResetTokenError ( )
300
359
301
360
# Update password and mark token as used
302
361
if authorized_user .password :
@@ -320,3 +379,81 @@ def logout():
320
379
response .delete_cookie ("access_token" )
321
380
response .delete_cookie ("refresh_token" )
322
381
return response
382
+
383
+
384
+ @router .post ("/update_email" )
385
+ async def request_email_update (
386
+ update : UpdateEmail = Depends (UpdateEmail .as_form ),
387
+ user : User = Depends (get_authenticated_user ),
388
+ session : Session = Depends (get_session )
389
+ ):
390
+ # Check if the new email is already registered
391
+ existing_user = session .exec (
392
+ select (User ).where (User .email == update .new_email )
393
+ ).first ()
394
+
395
+ if existing_user :
396
+ raise EmailAlreadyRegisteredError ()
397
+
398
+ if not user .id :
399
+ raise DataIntegrityError (resource = "User id" )
400
+
401
+ # Send confirmation email
402
+ send_email_update_confirmation (
403
+ current_email = user .email ,
404
+ new_email = update .new_email ,
405
+ user_id = user .id ,
406
+ session = session
407
+ )
408
+
409
+ return RedirectResponse (
410
+ url = "/profile?email_update_requested=true" ,
411
+ status_code = 303
412
+ )
413
+
414
+
415
+ @router .get ("/confirm_email_update" )
416
+ async def confirm_email_update (
417
+ user_id : int ,
418
+ token : str ,
419
+ new_email : str ,
420
+ session : Session = Depends (get_session )
421
+ ):
422
+ user , update_token = get_user_from_email_update_token (
423
+ user_id , token , session
424
+ )
425
+
426
+ if not user or not update_token :
427
+ raise InvalidResetTokenError ()
428
+
429
+ # Update email and mark token as used
430
+ user .email = new_email
431
+ update_token .used = True
432
+ session .commit ()
433
+
434
+ # Create new tokens with the updated email
435
+ access_token = create_access_token (data = {"sub" : new_email , "fresh" : True })
436
+ refresh_token = create_refresh_token (data = {"sub" : new_email })
437
+
438
+ # Set cookies before redirecting
439
+ response = RedirectResponse (
440
+ url = "/profile?email_updated=true" ,
441
+ status_code = 303
442
+ )
443
+
444
+ # Add secure cookie attributes
445
+ response .set_cookie (
446
+ key = "access_token" ,
447
+ value = access_token ,
448
+ httponly = True ,
449
+ secure = True ,
450
+ samesite = "lax"
451
+ )
452
+ response .set_cookie (
453
+ key = "refresh_token" ,
454
+ value = refresh_token ,
455
+ httponly = True ,
456
+ secure = True ,
457
+ samesite = "lax"
458
+ )
459
+ return response
0 commit comments