Skip to content

Commit a2f1d8c

Browse files
Merge branch 'main' into 41-make-sure-we-correctly-handle-password-reset-from-logged-in-users
2 parents 5f1d4b7 + 6d7f509 commit a2f1d8c

19 files changed

+1352
-158
lines changed

main.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException
99
from sqlmodel import Session
1010
from routers import authentication, organization, role, user
11-
from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
12-
from utils.models import User, Organization
11+
from utils.auth import (
12+
HTML_PASSWORD_PATTERN,
13+
get_user_with_relations,
14+
get_optional_user,
15+
NeedsNewTokens,
16+
get_user_from_reset_token,
17+
PasswordValidationError,
18+
AuthenticationError
19+
)
20+
from utils.models import User
1321
from utils.db import get_session, set_up_db
1422
from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES
1523

@@ -25,7 +33,7 @@ async def lifespan(app: FastAPI):
2533
# Optional shutdown logic
2634

2735

28-
app = FastAPI(lifespan=lifespan)
36+
app: FastAPI = FastAPI(lifespan=lifespan)
2937

3038
# Mount static files (e.g., CSS, JS)
3139
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -161,10 +169,12 @@ async def read_home(
161169

162170
@app.get("/login")
163171
async def read_login(
164-
params: dict = Depends(common_unauthenticated_parameters)
172+
params: dict = Depends(common_unauthenticated_parameters),
173+
email_updated: Optional[str] = "false"
165174
):
166175
if params["user"]:
167176
return RedirectResponse(url="/dashboard", status_code=302)
177+
params["email_updated"] = email_updated
168178
return templates.TemplateResponse(params["request"], "authentication/login.html", params)
169179

170180

@@ -174,6 +184,8 @@ async def read_register(
174184
):
175185
if params["user"]:
176186
return RedirectResponse(url="/dashboard", status_code=302)
187+
188+
params["password_pattern"] = HTML_PASSWORD_PATTERN
177189
return templates.TemplateResponse(params["request"], "authentication/register.html", params)
178190

179191

@@ -219,6 +231,7 @@ async def read_reset_password(
219231

220232
params["email"] = email
221233
params["token"] = token
234+
params["password_pattern"] = HTML_PASSWORD_PATTERN
222235

223236
return templates.TemplateResponse(params["request"], "authentication/reset_password.html", params)
224237

@@ -245,14 +258,18 @@ async def read_dashboard(
245258

246259
@app.get("/profile")
247260
async def read_profile(
248-
params: dict = Depends(common_authenticated_parameters)
261+
params: dict = Depends(common_authenticated_parameters),
262+
email_update_requested: Optional[str] = "false",
263+
email_updated: Optional[str] = "false"
249264
):
250265
# Add image constraints to the template context
251266
params.update({
252267
"max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB
253268
"min_dimension": MIN_DIMENSION,
254269
"max_dimension": MAX_DIMENSION,
255-
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys())
270+
"allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()),
271+
"email_update_requested": email_update_requested,
272+
"email_updated": email_updated
256273
})
257274
return templates.TemplateResponse(params["request"], "users/profile.html", params)
258275

routers/authentication.py

Lines changed: 145 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import RedirectResponse
77
from pydantic import BaseModel, EmailStr, ConfigDict
88
from sqlmodel import Session, select
9-
from utils.models import User, UserPassword
9+
from utils.models import User, UserPassword, DataIntegrityError
1010
from utils.auth import (
1111
get_session,
1212
get_user_from_reset_token,
@@ -18,13 +18,50 @@
1818
create_access_token,
1919
create_refresh_token,
2020
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
2225
)
2326

2427
logger = getLogger("uvicorn.error")
2528

2629
router = APIRouter(prefix="/auth", tags=["auth"])
2730

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+
2865

2966
# --- Server Request and Response Models ---
3067

@@ -105,6 +142,17 @@ async def as_form(
105142
)
106143

107144

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+
108156
# --- DB Request and Response Models ---
109157

110158

@@ -133,7 +181,7 @@ async def register(
133181
User.email == user.email)).first()
134182

135183
if db_user:
136-
raise HTTPException(status_code=400, detail="Email already registered")
184+
raise EmailAlreadyRegisteredError()
137185

138186
# Hash the password
139187
hashed_password = get_password_hash(user.password)
@@ -150,9 +198,20 @@ async def register(
150198
refresh_token = create_refresh_token(data={"sub": db_user.email})
151199
# Set cookie
152200
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+
)
156215

157216
return response
158217

@@ -167,7 +226,7 @@ async def login(
167226
User.email == user.email)).first()
168227

169228
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()
171230

172231
# Create access token
173232
access_token = create_access_token(
@@ -296,7 +355,7 @@ async def reset_password(
296355
user.email, user.token, session)
297356

298357
if not authorized_user or not reset_token:
299-
raise HTTPException(status_code=400, detail="Invalid or expired token")
358+
raise InvalidResetTokenError()
300359

301360
# Update password and mark token as used
302361
if authorized_user.password:
@@ -320,3 +379,81 @@ def logout():
320379
response.delete_cookie("access_token")
321380
response.delete_cookie("refresh_token")
322381
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

routers/organization.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
logger = getLogger("uvicorn.error")
1212

13+
router = APIRouter(prefix="/organizations", tags=["organizations"])
14+
1315
# --- Custom Exceptions ---
1416

1517

@@ -37,9 +39,6 @@ def __init__(self):
3739
)
3840

3941

40-
router = APIRouter(prefix="/organizations", tags=["organizations"])
41-
42-
4342
# --- Server Request and Response Models ---
4443

4544

routers/role.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,6 @@ class RoleUpdate(BaseModel):
8585
organization_id: int
8686
permissions: List[ValidPermissions]
8787

88-
@field_validator("id")
89-
@classmethod
90-
def validate_role_exists(cls, id: int, info):
91-
session = info.context.get("session")
92-
if session:
93-
role = session.get(Role, id)
94-
if not role or not role.id:
95-
raise RoleNotFoundError()
96-
return id
97-
9888
@classmethod
9989
async def as_form(
10090
cls,

routers/user.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,13 @@
1616
class UpdateProfile(BaseModel):
1717
"""Request model for updating user profile information"""
1818
name: str
19-
email: EmailStr
2019
avatar_file: Optional[bytes] = None
2120
avatar_content_type: Optional[str] = None
2221

2322
@classmethod
2423
async def as_form(
2524
cls,
2625
name: str = Form(...),
27-
email: EmailStr = Form(...),
2826
avatar_file: Optional[UploadFile] = File(None),
2927
):
3028
avatar_data = None
@@ -36,7 +34,6 @@ async def as_form(
3634

3735
return cls(
3836
name=name,
39-
email=email,
4037
avatar_file=avatar_data,
4138
avatar_content_type=avatar_content_type
4239
)
@@ -73,7 +70,6 @@ async def update_profile(
7370

7471
# Update user details
7572
user.name = user_profile.name
76-
user.email = user_profile.email
7773

7874
if user_profile.avatar_file:
7975
user.avatar_data = user_profile.avatar_file

templates/authentication/register.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@
2424
<!-- Password Input -->
2525
<div class="mb-3">
2626
<label for="password" class="form-label">Password</label>
27-
<!-- Make sure 9g,X*88w[6"W and ^94cPSf2^)z2^,& pass validation -->
2827
<input type="password" class="form-control" id="password" name="password"
29-
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/]{8,}"
28+
pattern="{{ password_pattern }}"
3029
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
3130
placeholder="Enter your password" required
3231
autocomplete="new-password">

templates/authentication/reset_password.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<div class="mb-3">
2020
<label for="new_password" class="form-label">New Password</label>
2121
<input type="password" class="form-control" id="new_password" name="new_password"
22-
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~]{8,}"
22+
pattern="{{ password_pattern }}"
2323
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
2424
required autocomplete="new-password">
2525
<div class="invalid-feedback">

templates/components/header.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
<li class="nav-item dropdown">
2525
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
2626
<button class="profile-button btn p-0 border-0 bg-transparent">
27-
{{ render_silhouette() }}
27+
{% if user.avatar_data %}
28+
<img src="{{ url_for('get_avatar') }}" alt="User Avatar" class="d-inline-block align-top" width="30" height="30" style="border-radius: 50%;">
29+
{% else %}
30+
{{ render_silhouette() }}
31+
{% endif %}
2832
</button>
2933
</a>
3034
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">

0 commit comments

Comments
 (0)