Add password reset functionality with email notifications

- 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
This commit is contained in:
Yoga Pangestu 2026-07-22 20:22:58 +07:00
parent 579f8a3f21
commit 104a58e6e7
11 changed files with 355 additions and 2 deletions

22
.env.example Normal file
View File

@ -0,0 +1,22 @@
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=pangestu-profitra
DB_ECHO=false
# JWT
SECRET_KEY=change-me-to-a-random-secret-key
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Google OAuth
GOOGLE_CLIENT_ID=
# Resend (Email Service)
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
EMAIL_FROM=noreply@profitra.id
# Frontend URL (for password reset link)
FRONTEND_URL=http://localhost:3000

View File

@ -14,6 +14,9 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
GOOGLE_CLIENT_ID: str = ""
RESEND_API_KEY: str = ""
EMAIL_FROM: str = "noreply@profitra.id"
FRONTEND_URL: str = "http://localhost:3000"
@property
def DATABASE_URL(self) -> str:

View File

@ -1,5 +1,6 @@
from app.models.user import User
from app.models.user_profile import UserProfile
from app.models.password_reset_token import PasswordResetToken
from app.models.business_type import BusinessType
from app.models.plan import Plan
from app.models.tenant import Tenant
@ -8,6 +9,7 @@ from app.models.subscription import Subscription
__all__ = [
"User",
"UserProfile",
"PasswordResetToken",
"BusinessType",
"Plan",
"Tenant",

View File

@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.mysql import CHAR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[str] = mapped_column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(CHAR(36), ForeignKey("users.id"), index=True)
token: Mapped[str] = mapped_column(String(255), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime)
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
user: Mapped["User"] = relationship(back_populates="password_reset_tokens")

View File

@ -23,3 +23,4 @@ class User(Base):
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False)
password_reset_tokens: Mapped[list["PasswordResetToken"]] = relationship(back_populates="user")

View File

@ -11,6 +11,7 @@ from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.user_profile import UserProfile
from app.schemas.auth import ForgotPasswordRequest, ResetPasswordRequest
from app.schemas.user import (
GoogleLoginRequest,
LoginRequest,
@ -19,6 +20,7 @@ from app.schemas.user import (
UserResponse,
)
from app.security import create_access_token, hash_password, verify_password
from app.services.auth_service import forgot_password, reset_password
router = APIRouter(prefix="/auth", tags=["auth"])
@ -155,10 +157,18 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
elif user.profile.full_name != name:
user.profile.full_name = name
db.commit()
db.refresh(user)
access_token = create_access_token({"sub": user.id})
return LoginResponse(
access_token=access_token,
user=user,
)
@router.post("/forgot-password")
def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)):
return forgot_password(db, data.email)
@router.post("/reset-password")
def reset_password_endpoint(data: ResetPasswordRequest, db: Session = Depends(get_db)):
return reset_password(db, data.token, data.password)

25
app/schemas/auth.py Normal file
View File

@ -0,0 +1,25 @@
from pydantic import BaseModel, EmailStr, field_validator
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
password: str
password_confirmation: str
@field_validator("password")
@classmethod
def password_min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Kata sandi minimal 8 karakter")
return v
@field_validator("password_confirmation")
@classmethod
def passwords_match(cls, v: str, info) -> str:
if "password" in info.data and v != info.data["password"]:
raise ValueError("Konfirmasi kata sandi tidak cocok")
return v

View File

@ -0,0 +1,116 @@
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"}

View File

@ -0,0 +1,55 @@
import resend
from app.config import settings
def send_password_reset_email(to_email: str, reset_url: str) -> None:
resend.api_key = settings.RESEND_API_KEY
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body style="font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #fafafa; margin: 0; padding: 40px 20px;">
<div style="max-width: 480px; margin: 0 auto; background: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
<div style="text-align: center; margin: 0 0 24px;">
<div style="display: inline-block; width: 48px; height: 48px; background-color: #f0fdf4; border-radius: 12px; line-height: 48px;">
<span style="font-size: 24px;">🔒</span>
</div>
</div>
<h1 style="font-size: 20px; font-weight: 600; color: #1a1a1a; margin: 0 0 8px; text-align: center;">Reset Kata Sandi</h1>
<p style="font-size: 14px; color: #71717a; margin: 0 0 24px; line-height: 1.6; text-align: center;">
Anda menerima email ini karena kami menerima permintaan reset kata sandi untuk akun Anda.
</p>
<div style="text-align: center; margin: 0 0 24px;">
<a href="{reset_url}"
style="display: inline-block; background-color: #22C55E; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 30px; font-size: 15px; font-weight: 500;">
Reset Kata Sandi
</a>
</div>
<p style="font-size: 13px; color: #a1a1aa; margin: 0 0 8px; line-height: 1.6; text-align: center;">
Link ini berlaku selama <strong>1 jam</strong>.
</p>
<p style="font-size: 13px; color: #a1a1aa; margin: 0; line-height: 1.6; text-align: center;">
Jika Anda tidak meminta reset kata sandi, abaikan email ini.
</p>
</div>
<p style="font-size: 12px; color: #d4d4d8; text-align: center; margin: 24px 0 0;">
&copy; 2026 Profitra. Semua hak dilindungi.
</p>
</body>
</html>
"""
params: resend.Emails.SendParams = {
"from": settings.EMAIL_FROM,
"to": [to_email],
"subject": "Reset Kata Sandi - Profitra",
"html": html_content,
}
resend.Emails.send(params)

View File

@ -0,0 +1,97 @@
# 2025-07-22: Forgot Password - Backend
## Summary
Menambahkan fitur Lupa Kata Sandi: endpoint `POST /v1/auth/forgot-password` dan `POST /v1/auth/reset-password` dengan email via Resend.
---
## Changes
| File | Change |
|------|--------|
| `app/config.py` | Tambah `RESEND_API_KEY`, `EMAIL_FROM`, `FRONTEND_URL` |
| `.env.example` | **BARU** - Template env lengkap (DB, JWT, Google, Resend, Frontend URL) |
| `app/models/password_reset_token.py` | **BARU** - Model `password_reset_tokens` (id, user_id, token, expires_at, used_at, created_at) |
| `app/models/user.py` | Tambah relationship `password_reset_tokens` |
| `app/models/__init__.py` | Register `PasswordResetToken` |
| `app/schemas/auth.py` | **BARU** - `ForgotPasswordRequest(email)`, `ResetPasswordRequest(token, password, password_confirmation)` |
| `app/services/email_service.py` | **BARU** - Kirim email HTML via Resend SDK (green theme, Poppins font, rounded UI) |
| `app/services/auth_service.py` | **BARU** - Logic `forgot_password()` dan `reset_password()` |
| `app/routers/auth.py` | Tambah endpoint `POST /v1/auth/forgot-password` dan `POST /v1/auth/reset-password` |
| `requirements.txt` | Tambah `resend==2.10.0` |
---
## API Endpoints
### POST /v1/auth/forgot-password
Request:
```json
{ "email": "user@example.com" }
```
Logic:
1. Cek email ada di DB → kalau tidak, return 404
2. Cek apakah ada token aktif (unused + belum expired) → kalau ada, return "link masih aktif"
3. Invalidate token lama, buat token baru (bcrypt hashed), simpan ke DB
4. Kirim email via Resend dengan link reset
5. Token berlaku 1 jam, single-use
Response (200):
```json
{ "message": "Link reset password telah dikirim ke email Anda" }
```
### POST /v1/auth/reset-password
Request:
```json
{ "token": "...", "password": "newpassword123", "password_confirmation": "newpassword123" }
```
Logic:
1. Cari token unused di DB, verifikasi hash
2. Cek expiry (1 jam)
3. Update password user
4. Invalidate SEMUA token lama user tersebut
---
## Database Table
```sql
CREATE TABLE password_reset_tokens (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_token (token),
UNIQUE INDEX idx_token_unique (token),
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
---
## Security Notes
- Token asli dikirim ke user via email, yang disimpan di DB adalah **bcrypt hash**
- Token berlaku **1 jam**
- **Single-use**: setelah dipakai, `used_at` diisi
- Semua token lama user di-invalidate saat:
- Request forgot-password baru
- Password berhasil di-reset
- Anti-spam: jika token aktif masih ada, tidak kirim email baru
---
## Troubleshooting
- Email tidak sampai: cek `RESEND_API_KEY` di `.env`, cek folder spam, pastikan domain verified di Resend dashboard
- Error 500: cek terminal backend, pastikan tabel `password_reset_tokens` sudah dibuat
- Server harus di-restart setelah tambah env vars baru

View File

@ -45,4 +45,5 @@ urllib3==2.7.0
uvicorn==0.51.0
uvloop==0.22.1
watchfiles==1.2.0
resend==2.10.0
websockets==16.1