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:
parent
579f8a3f21
commit
104a58e6e7
22
.env.example
Normal file
22
.env.example
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
21
app/models/password_reset_token.py
Normal file
21
app/models/password_reset_token.py
Normal 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")
|
||||
@ -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")
|
||||
|
||||
@ -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
25
app/schemas/auth.py
Normal 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
|
||||
116
app/services/auth_service.py
Normal file
116
app/services/auth_service.py
Normal 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"}
|
||||
55
app/services/email_service.py
Normal file
55
app/services/email_service.py
Normal 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;">
|
||||
© 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)
|
||||
97
docs/history/2025-07-22-forgot-password.md
Normal file
97
docs/history/2025-07-22-forgot-password.md
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user