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"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||||
GOOGLE_CLIENT_ID: str = ""
|
GOOGLE_CLIENT_ID: str = ""
|
||||||
|
RESEND_API_KEY: str = ""
|
||||||
|
EMAIL_FROM: str = "noreply@profitra.id"
|
||||||
|
FRONTEND_URL: str = "http://localhost:3000"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def DATABASE_URL(self) -> str:
|
def DATABASE_URL(self) -> str:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_profile import UserProfile
|
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.business_type import BusinessType
|
||||||
from app.models.plan import Plan
|
from app.models.plan import Plan
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
@ -8,6 +9,7 @@ from app.models.subscription import Subscription
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
"UserProfile",
|
"UserProfile",
|
||||||
|
"PasswordResetToken",
|
||||||
"BusinessType",
|
"BusinessType",
|
||||||
"Plan",
|
"Plan",
|
||||||
"Tenant",
|
"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)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False)
|
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.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_profile import UserProfile
|
from app.models.user_profile import UserProfile
|
||||||
|
from app.schemas.auth import ForgotPasswordRequest, ResetPasswordRequest
|
||||||
from app.schemas.user import (
|
from app.schemas.user import (
|
||||||
GoogleLoginRequest,
|
GoogleLoginRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@ -19,6 +20,7 @@ from app.schemas.user import (
|
|||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from app.security import create_access_token, hash_password, verify_password
|
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"])
|
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:
|
elif user.profile.full_name != name:
|
||||||
user.profile.full_name = name
|
user.profile.full_name = name
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
|
||||||
|
|
||||||
access_token = create_access_token({"sub": user.id})
|
access_token = create_access_token({"sub": user.id})
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=user,
|
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
|
uvicorn==0.51.0
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
watchfiles==1.2.0
|
watchfiles==1.2.0
|
||||||
|
resend==2.10.0
|
||||||
websockets==16.1
|
websockets==16.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user