Skip to content

Commit 4ea6509

Browse files
Don't return users with deleted column set to True
1 parent 8a62883 commit 4ea6509

File tree

6 files changed

+179
-27
lines changed

6 files changed

+179
-27
lines changed

routers/authentication.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# auth.py
2-
from logging import getLogger
2+
from logging import getLogger, DEBUG
33
from typing import Optional
44
from datetime import datetime
55
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form
@@ -22,6 +22,7 @@
2222
)
2323

2424
logger = getLogger("uvicorn.error")
25+
logger.setLevel(DEBUG)
2526

2627
router = APIRouter(prefix="/auth", tags=["auth"])
2728

@@ -126,7 +127,9 @@ async def register(
126127
session: Session = Depends(get_session),
127128
) -> RedirectResponse:
128129
db_user = session.exec(select(User).where(
129-
User.email == user.email)).first()
130+
User.email == user.email,
131+
User.deleted == False
132+
)).first()
130133

131134
if db_user:
132135
raise HTTPException(status_code=400, detail="Email already registered")
@@ -156,7 +159,9 @@ async def login(
156159
session: Session = Depends(get_session),
157160
) -> RedirectResponse:
158161
db_user = session.exec(select(User).where(
159-
User.email == user.email)).first()
162+
User.email == user.email,
163+
User.deleted == False
164+
)).first()
160165
if not db_user or not verify_password(user.password, db_user.hashed_password):
161166
raise HTTPException(status_code=400, detail="Invalid credentials")
162167

@@ -205,7 +210,9 @@ async def refresh_token(
205210

206211
user_email = decoded_token.get("sub")
207212
db_user = session.exec(select(User).where(
208-
User.email == user_email)).first()
213+
User.email == user_email,
214+
User.deleted == False
215+
)).first()
209216
if not db_user:
210217
return RedirectResponse(url="/login", status_code=303)
211218

@@ -239,7 +246,9 @@ async def forgot_password(
239246
session: Session = Depends(get_session)
240247
):
241248
db_user = session.exec(select(User).where(
242-
User.email == user.email)).first()
249+
User.email == user.email,
250+
User.deleted == False
251+
)).first()
243252

244253
if db_user:
245254
background_tasks.add_task(send_reset_email, user.email, session)
@@ -255,8 +264,13 @@ async def reset_password(
255264
authorized_user, reset_token = get_user_from_reset_token(
256265
user.email, user.token, session)
257266

258-
if not authorized_user or not reset_token:
267+
logger.debug(f"authorized_user: {authorized_user}")
268+
logger.debug(f"reset_token: {reset_token}")
269+
270+
if not reset_token:
259271
raise HTTPException(status_code=400, detail="Invalid or expired token")
272+
elif not authorized_user:
273+
raise HTTPException(status_code=400, detail="User not found")
260274

261275
# Update password and mark token as used
262276
authorized_user.hashed_password = get_password_hash(user.new_password)

routers/user.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ async def as_form(
3737
return cls(confirm_delete_password=confirm_delete_password)
3838

3939

40+
class UpdateProfile(BaseModel):
41+
"""Request model for updating user profile information"""
42+
name: str
43+
email: EmailStr
44+
avatar_url: str
45+
46+
@classmethod
47+
async def as_form(
48+
cls,
49+
name: str = Form(...),
50+
email: EmailStr = Form(...),
51+
avatar_url: str = Form(...),
52+
):
53+
return cls(name=name, email=email, avatar_url=avatar_url)
54+
55+
4056
# -- Routes --
4157

4258

@@ -48,37 +64,38 @@ async def view_profile(
4864
return {"user": current_user}
4965

5066

51-
@router.post("/edit_profile", response_class=RedirectResponse)
52-
async def edit_profile(
53-
name: str = Form(...),
54-
email: str = Form(...),
55-
avatar_url: str = Form(...),
67+
@router.post("/update_profile", response_class=RedirectResponse)
68+
async def update_profile(
69+
profile_update: UpdateProfile = Depends(UpdateProfile.as_form),
5670
current_user: User = Depends(get_authenticated_user),
5771
session: Session = Depends(get_session)
5872
):
5973
# Update user details
60-
current_user.name = name
61-
current_user.email = email
62-
current_user.avatar_url = avatar_url
74+
current_user.name = profile_update.name
75+
current_user.email = profile_update.email
76+
current_user.avatar_url = profile_update.avatar_url
6377
session.commit()
6478
session.refresh(current_user)
6579
return RedirectResponse(url="/profile", status_code=303)
6680

6781

6882
@router.post("/delete_account", response_class=RedirectResponse)
6983
async def delete_account(
70-
confirm_delete_password: str = Form(...),
84+
user_delete_account: UserDeleteAccount = Depends(
85+
UserDeleteAccount.as_form),
7186
current_user: User = Depends(get_authenticated_user),
7287
session: Session = Depends(get_session)
7388
):
74-
if not verify_password(confirm_delete_password, current_user.hashed_password):
89+
if not verify_password(user_delete_account.confirm_delete_password, current_user.hashed_password):
7590
raise HTTPException(status_code=400, detail="Password is incorrect")
7691

7792
# Mark the user as deleted
7893
current_user.deleted = True
7994
session.commit()
80-
#Logs Out
81-
router.get("/logout", response_class=RedirectResponse)
82-
# Deletes user
83-
session.delete(current_user)
84-
return RedirectResponse(url="/", status_code=303)
95+
96+
# Delete the user's access and refresh tokens to force a logout
97+
response = RedirectResponse(url="/", status_code=303)
98+
response.delete_cookie("access_token")
99+
response.delete_cookie("refresh_token")
100+
101+
return response

templates/users/profile.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ <h1 class="mb-4">User Profile</h1>
3434
Edit Profile
3535
</div>
3636
<div class="card-body">
37-
<form action="{{ url_for('edit_profile') }}" method="post">
37+
<form action="{{ url_for('update_profile') }}" method="post">
3838
<div class="mb-3">
3939
<label for="name" class="form-label">Name</label>
4040
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">

tests/test_authentication.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ def test_logout_endpoint(client: TestClient):
261261

262262

263263
def test_register_with_existing_email(client: TestClient, test_user: User):
264+
"""Test that registration fails with an existing non-deleted user's email"""
265+
# Ensure test user is not deleted
266+
assert not test_user.deleted
267+
264268
response = client.post(
265269
"/auth/register",
266270
data={
@@ -273,6 +277,34 @@ def test_register_with_existing_email(client: TestClient, test_user: User):
273277
assert response.status_code == 400
274278

275279

280+
def test_register_with_deleted_user_email(client: TestClient, test_user: User, session: Session):
281+
"""Test that registration succeeds with a deleted user's email"""
282+
# Mark test user as deleted
283+
test_user.deleted = True
284+
session.add(test_user)
285+
session.commit()
286+
287+
response = client.post(
288+
"/auth/register",
289+
data={
290+
"name": "New User",
291+
"email": test_user.email,
292+
"password": "Test123!@#",
293+
"confirm_password": "Test123!@#"
294+
},
295+
follow_redirects=False
296+
)
297+
assert response.status_code == 303
298+
299+
# Verify new user was created
300+
new_user = session.exec(select(User).where(
301+
User.email == test_user.email,
302+
User.deleted == False
303+
)).first()
304+
assert new_user is not None
305+
assert new_user.id != test_user.id
306+
307+
276308
def test_login_with_invalid_credentials(client: TestClient, test_user: User):
277309
response = client.post(
278310
"/auth/login",
@@ -361,3 +393,74 @@ def test_password_reset_email_url(client: TestClient, session: Session, test_use
361393
assert parsed.path == str(reset_password_path)
362394
assert query_params["email"][0] == test_user.email
363395
assert query_params["token"][0] == reset_token.token
396+
397+
398+
def test_deleted_user_cannot_login(client: TestClient, test_user: User, session: Session):
399+
"""Test that a deleted user cannot log in"""
400+
# First mark the user as deleted
401+
test_user.deleted = True
402+
session.add(test_user)
403+
session.commit()
404+
405+
response = client.post(
406+
"/auth/login",
407+
data={
408+
"email": test_user.email,
409+
"password": "Test123!@#"
410+
}
411+
)
412+
assert response.status_code == 400
413+
414+
415+
def test_deleted_user_cannot_use_tokens(client: TestClient, test_user: User, session: Session):
416+
"""Test that a deleted user's tokens become invalid"""
417+
# Create tokens before marking user as deleted
418+
access_token = create_access_token({"sub": test_user.email})
419+
refresh_token = create_refresh_token({"sub": test_user.email})
420+
421+
# Mark user as deleted
422+
test_user.deleted = True
423+
session.add(test_user)
424+
session.commit()
425+
426+
# Set tokens in cookies
427+
client.cookies.set("access_token", access_token)
428+
client.cookies.set("refresh_token", refresh_token)
429+
430+
# Try to refresh tokens
431+
response = client.post("/auth/refresh", follow_redirects=False)
432+
assert response.status_code == 303 # user is redirected to login
433+
434+
435+
def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User):
436+
"""Test that a deleted user cannot use a previously issued reset token"""
437+
# First create a reset token
438+
response = client.post(
439+
"/auth/forgot_password",
440+
data={"email": test_user.email},
441+
follow_redirects=False
442+
)
443+
assert response.status_code == 303
444+
445+
# Get the reset token
446+
reset_token = session.exec(select(PasswordResetToken)
447+
.where(PasswordResetToken.user_id == test_user.id)).first()
448+
assert reset_token is not None
449+
450+
# Now mark user as deleted
451+
test_user.deleted = True
452+
session.add(test_user)
453+
session.commit()
454+
455+
# Try to use the reset token
456+
response = client.post(
457+
"/auth/reset_password",
458+
data={
459+
"email": test_user.email,
460+
"token": reset_token.token,
461+
"new_password": "NewPass123!@#",
462+
"confirm_new_password": "NewPass123!@#"
463+
},
464+
follow_redirects=False
465+
)
466+
assert response.status_code == 400

utils/auth.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,9 @@ def validate_token_and_get_user(
180180
if decoded_token:
181181
user_email = decoded_token.get("sub")
182182
user = session.exec(select(User).where(
183-
User.email == user_email)).first()
183+
User.email == user_email,
184+
User.deleted == False
185+
)).first()
184186
if user:
185187
if token_type == "refresh":
186188
new_access_token = create_access_token(
@@ -275,7 +277,10 @@ def generate_password_reset_url(email: str, token: str) -> str:
275277

276278
def send_reset_email(email: str, session: Session):
277279
# Check for an existing unexpired token
278-
user = session.exec(select(User).where(User.email == email)).first()
280+
user = session.exec(select(User).where(
281+
User.email == email,
282+
User.deleted == False
283+
)).first()
279284
if user:
280285
existing_token = session.exec(
281286
select(PasswordResetToken)
@@ -327,7 +332,11 @@ def get_user_from_reset_token(email: str, token: str, session: Session) -> tuple
327332

328333
user = session.exec(select(User).where(
329334
User.email == email,
330-
User.id == reset_token.user_id
335+
User.id == reset_token.user_id,
336+
User.deleted == False
331337
)).first()
332338

339+
if not user:
340+
return None, None
341+
333342
return user, reset_token

utils/models.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime, UTC, timedelta
44
from typing import Optional, List
55
from sqlmodel import SQLModel, Field, Relationship
6-
from sqlalchemy import Column, Enum as SQLAlchemyEnum
6+
from sqlalchemy import Column, Enum as SQLAlchemyEnum, Index
77

88

99
def utc_time():
@@ -89,7 +89,7 @@ class PasswordResetToken(SQLModel, table=True):
8989
class User(SQLModel, table=True):
9090
id: Optional[int] = Field(default=None, primary_key=True)
9191
name: str
92-
email: str = Field(index=True, unique=True)
92+
email: str = Field(index=True)
9393
hashed_password: str
9494
avatar_url: Optional[str] = None
9595
organization_id: Optional[int] = Field(
@@ -105,6 +105,15 @@ class User(SQLModel, table=True):
105105
password_reset_tokens: List["PasswordResetToken"] = Relationship(
106106
back_populates="user")
107107

108+
__table_args__ = (
109+
Index(
110+
'ix_user_email_unique_active',
111+
'email',
112+
unique=True,
113+
postgresql_where=(deleted == False)
114+
),
115+
)
116+
108117

109118
class UserOrganizationLink(SQLModel, table=True):
110119
id: Optional[int] = Field(default=None, primary_key=True)

0 commit comments

Comments
 (0)