- Implemented endpoints for forgot password and reset password - Added PasswordResetToken model and relationships - Integrated email service using Resend for sending reset links - Updated configuration for email and frontend URL - Enhanced user model to include password reset tokens - Added necessary schemas for password reset requests - Updated requirements to include Resend SDK - Created comprehensive documentation for the new feature
117 lines
3.3 KiB
Python
117 lines
3.3 KiB
Python
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import settings
|
|
from app.models.password_reset_token import PasswordResetToken
|
|
from app.models.user import User
|
|
from app.security import hash_password, verify_password
|
|
from app.services.email_service import send_password_reset_email
|
|
|
|
|
|
def _generate_token() -> str:
|
|
return secrets.token_urlsafe(48)
|
|
|
|
|
|
def _hash_token(token: str) -> str:
|
|
return hash_password(token)
|
|
|
|
|
|
def _verify_token_hash(plain_token: str, hashed_token: str) -> bool:
|
|
return verify_password(plain_token, hashed_token)
|
|
|
|
|
|
def forgot_password(db: Session, email: str) -> dict:
|
|
user = db.query(User).filter(
|
|
User.email == email,
|
|
User.deleted_at.is_(None),
|
|
).first()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={"message": "Email tidak ditemukan."},
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
active_token = db.query(PasswordResetToken).filter(
|
|
PasswordResetToken.user_id == user.id,
|
|
PasswordResetToken.used_at.is_(None),
|
|
PasswordResetToken.expires_at > now,
|
|
).first()
|
|
|
|
if active_token:
|
|
return {"message": "Link reset password sudah dikirim ke email Anda dan masih aktif. Silakan cek inbox atau folder spam Anda."}
|
|
|
|
db.query(PasswordResetToken).filter(
|
|
PasswordResetToken.user_id == user.id,
|
|
PasswordResetToken.used_at.is_(None),
|
|
).update({"used_at": now})
|
|
|
|
raw_token = _generate_token()
|
|
hashed = _hash_token(raw_token)
|
|
|
|
reset_token = PasswordResetToken(
|
|
user_id=user.id,
|
|
token=hashed,
|
|
expires_at=now + timedelta(hours=1),
|
|
)
|
|
db.add(reset_token)
|
|
db.commit()
|
|
|
|
reset_url = f"{settings.FRONTEND_URL}/auth/reset-password?token={raw_token}"
|
|
|
|
send_password_reset_email(user.email, reset_url)
|
|
|
|
return {"message": "Link reset password telah dikirim ke email Anda"}
|
|
|
|
|
|
def reset_password(db: Session, token: str, password: str) -> dict:
|
|
now = datetime.now(timezone.utc)
|
|
|
|
tokens = db.query(PasswordResetToken).filter(
|
|
PasswordResetToken.user_id.isnot(None),
|
|
PasswordResetToken.used_at.is_(None),
|
|
).all()
|
|
|
|
matched = None
|
|
for t in tokens:
|
|
if _verify_token_hash(token, t.token):
|
|
matched = t
|
|
break
|
|
|
|
if not matched:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"message": "Token tidak valid atau sudah kedaluwarsa."},
|
|
)
|
|
|
|
if matched.expires_at.replace(tzinfo=timezone.utc) < now:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"message": "Token tidak valid atau sudah kedaluwarsa."},
|
|
)
|
|
|
|
matched.used_at = now
|
|
|
|
user = db.query(User).filter(User.id == matched.user_id).first()
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"message": "Token tidak valid atau sudah kedaluwarsa."},
|
|
)
|
|
|
|
user.password = hash_password(password)
|
|
|
|
db.query(PasswordResetToken).filter(
|
|
PasswordResetToken.user_id == user.id,
|
|
PasswordResetToken.used_at.is_(None),
|
|
).update({"used_at": now})
|
|
|
|
db.commit()
|
|
|
|
return {"message": "Password berhasil diubah"}
|