Skip to content

Commit 6509aba

Browse files
committed
add: implement email verification and password reset endpoints with token handling
1 parent abfa154 commit 6509aba

File tree

1 file changed

+82
-0
lines changed

1 file changed

+82
-0
lines changed

backend/app/api/v1/auth_email.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from app.core.config import settings
4+
from app.core.dependencies import get_db
5+
from app.core.security import hash_password
6+
from app.crud.email_token import consume_email_token, issue_email_token
7+
from app.models.user import User
8+
from app.schemas.email_token import CompletePasswordReset, RequestPasswordReset, RequestVerifyEmail
9+
from app.services.mailer import send_email
10+
from fastapi import APIRouter, Depends, HTTPException, status
11+
from sqlalchemy import select
12+
from sqlalchemy.ext.asyncio import AsyncSession
13+
14+
router = APIRouter(prefix="/auth", tags=["Auth Email"])
15+
16+
17+
# 1) Send verification email (after signup or on demand)
18+
@router.post("/verify-email/request", status_code=status.HTTP_202_ACCEPTED)
19+
async def request_verify_email(
20+
body: RequestVerifyEmail,
21+
db: AsyncSession = Depends(get_db),
22+
):
23+
24+
res = await db.execute(select(User).where(User.email == body.email))
25+
user = res.scalar_one_or_none()
26+
# Always respond 202 to prevent enumeration, but only send if user exists
27+
if user and not user.email_verified:
28+
tok, raw = await issue_email_token(db, user_id=user.id, purpose="verify")
29+
link = f"{settings.FRONTEND_ORIGIN}/auth/verify-email?token={raw}"
30+
html = f"<p>Verify your Taskaza email by clicking <a href='{link}'>this link</a>. It expires in 24 hours.</p>"
31+
await send_email(user.email, "Verify your Taskaza email", html)
32+
return {"message": "If your email exists and is unverified, a link has been sent."}
33+
34+
35+
# 2) Complete verification
36+
@router.post("/verify-email/complete", status_code=status.HTTP_200_OK)
37+
async def complete_verify_email(
38+
token: str,
39+
db: AsyncSession = Depends(get_db),
40+
):
41+
tok = await consume_email_token(db, token, purpose="verify")
42+
if not tok:
43+
raise HTTPException(status_code=400, detail="Invalid or expired token")
44+
45+
user = tok.user
46+
if not user.email_verified:
47+
user.email_verified = True
48+
await db.commit()
49+
return {"message": "Email verified successfully."}
50+
51+
52+
# 3) Request password reset
53+
@router.post("/password-reset/request", status_code=status.HTTP_202_ACCEPTED)
54+
async def request_password_reset(
55+
body: RequestPasswordReset,
56+
db: AsyncSession = Depends(get_db),
57+
):
58+
res = await db.execute(select(User).where(User.email == body.email))
59+
user = res.scalar_one_or_none()
60+
if user:
61+
tok, raw = await issue_email_token(db, user_id=user.id, purpose="reset")
62+
link = f"{settings.FRONTEND_ORIGIN}/auth/reset-password?token={raw}"
63+
html = f"<p>Reset your Taskaza password <a href='{link}'>here</a>. Link valid for 1 hour.</p>"
64+
await send_email(user.email, "Reset your Taskaza password", html)
65+
return {"message": "If the account exists, a reset link has been sent."}
66+
67+
68+
# 4) Complete password reset
69+
@router.post("/password-reset/complete", status_code=status.HTTP_200_OK)
70+
async def complete_password_reset(
71+
body: CompletePasswordReset,
72+
db: AsyncSession = Depends(get_db),
73+
):
74+
tok = await consume_email_token(db, body.token, purpose="reset")
75+
if not tok:
76+
raise HTTPException(status_code=400, detail="Invalid or expired token")
77+
78+
user = tok.user
79+
80+
user.hashed_password = hash_password(body.new_password)
81+
await db.commit()
82+
return {"message": "Password reset successfully."}

0 commit comments

Comments
 (0)