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
@@ -102,6 +139,17 @@ async def as_form(
102
139
new_password = new_password , confirm_new_password = confirm_new_password )
103
140
104
141
142
+ class UpdateEmail (BaseModel ):
143
+ new_email : EmailStr
144
+
145
+ @classmethod
146
+ async def as_form (
147
+ cls ,
148
+ new_email : EmailStr = Form (...)
149
+ ):
150
+ return cls (new_email = new_email )
151
+
152
+
105
153
# --- DB Request and Response Models ---
106
154
107
155
@@ -130,7 +178,7 @@ async def register(
130
178
User .email == user .email )).first ()
131
179
132
180
if db_user :
133
- raise HTTPException ( status_code = 400 , detail = "Email already registered" )
181
+ raise EmailAlreadyRegisteredError ( )
134
182
135
183
# Hash the password
136
184
hashed_password = get_password_hash (user .password )
@@ -147,9 +195,20 @@ async def register(
147
195
refresh_token = create_refresh_token (data = {"sub" : db_user .email })
148
196
# Set cookie
149
197
response = RedirectResponse (url = "/" , status_code = 303 )
150
- response .set_cookie (key = "access_token" , value = access_token , httponly = True )
151
- response .set_cookie (key = "refresh_token" ,
152
- value = refresh_token , httponly = True )
198
+ response .set_cookie (
199
+ key = "access_token" ,
200
+ value = access_token ,
201
+ httponly = True ,
202
+ secure = True ,
203
+ samesite = "strict"
204
+ )
205
+ response .set_cookie (
206
+ key = "refresh_token" ,
207
+ value = refresh_token ,
208
+ httponly = True ,
209
+ secure = True ,
210
+ samesite = "strict"
211
+ )
153
212
154
213
return response
155
214
@@ -164,7 +223,7 @@ async def login(
164
223
User .email == user .email )).first ()
165
224
166
225
if not db_user or not db_user .password or not verify_password (user .password , db_user .password .hashed_password ):
167
- raise HTTPException ( status_code = 400 , detail = "Invalid credentials" )
226
+ raise InvalidCredentialsError ( )
168
227
169
228
# Create access token
170
229
access_token = create_access_token (
@@ -262,7 +321,7 @@ async def reset_password(
262
321
user .email , user .token , session )
263
322
264
323
if not authorized_user or not reset_token :
265
- raise HTTPException ( status_code = 400 , detail = "Invalid or expired token" )
324
+ raise InvalidResetTokenError ( )
266
325
267
326
# Update password and mark token as used
268
327
if authorized_user .password :
@@ -289,3 +348,81 @@ def logout():
289
348
response .delete_cookie ("access_token" )
290
349
response .delete_cookie ("refresh_token" )
291
350
return response
351
+
352
+
353
+ @router .post ("/update_email" )
354
+ async def request_email_update (
355
+ update : UpdateEmail = Depends (UpdateEmail .as_form ),
356
+ user : User = Depends (get_authenticated_user ),
357
+ session : Session = Depends (get_session )
358
+ ):
359
+ # Check if the new email is already registered
360
+ existing_user = session .exec (
361
+ select (User ).where (User .email == update .new_email )
362
+ ).first ()
363
+
364
+ if existing_user :
365
+ raise EmailAlreadyRegisteredError ()
366
+
367
+ if not user .id :
368
+ raise DataIntegrityError (resource = "User id" )
369
+
370
+ # Send confirmation email
371
+ send_email_update_confirmation (
372
+ current_email = user .email ,
373
+ new_email = update .new_email ,
374
+ user_id = user .id ,
375
+ session = session
376
+ )
377
+
378
+ return RedirectResponse (
379
+ url = "/profile?email_update_requested=true" ,
380
+ status_code = 303
381
+ )
382
+
383
+
384
+ @router .get ("/confirm_email_update" )
385
+ async def confirm_email_update (
386
+ user_id : int ,
387
+ token : str ,
388
+ new_email : str ,
389
+ session : Session = Depends (get_session )
390
+ ):
391
+ user , update_token = get_user_from_email_update_token (
392
+ user_id , token , session
393
+ )
394
+
395
+ if not user or not update_token :
396
+ raise InvalidResetTokenError ()
397
+
398
+ # Update email and mark token as used
399
+ user .email = new_email
400
+ update_token .used = True
401
+ session .commit ()
402
+
403
+ # Create new tokens with the updated email
404
+ access_token = create_access_token (data = {"sub" : new_email , "fresh" : True })
405
+ refresh_token = create_refresh_token (data = {"sub" : new_email })
406
+
407
+ # Set cookies before redirecting
408
+ response = RedirectResponse (
409
+ url = "/profile?email_updated=true" ,
410
+ status_code = 303
411
+ )
412
+
413
+ # Add secure cookie attributes
414
+ response .set_cookie (
415
+ key = "access_token" ,
416
+ value = access_token ,
417
+ httponly = True ,
418
+ secure = True ,
419
+ samesite = "lax"
420
+ )
421
+ response .set_cookie (
422
+ key = "refresh_token" ,
423
+ value = refresh_token ,
424
+ httponly = True ,
425
+ secure = True ,
426
+ samesite = "lax"
427
+ )
428
+ return response
0 commit comments