diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e672077 --- /dev/null +++ b/.env.example @@ -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 diff --git a/app/config.py b/app/config.py index cb797d3..11acf81 100644 --- a/app/config.py +++ b/app/config.py @@ -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: diff --git a/app/models/__init__.py b/app/models/__init__.py index 24909e3..a8ac254 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/password_reset_token.py b/app/models/password_reset_token.py new file mode 100644 index 0000000..252951e --- /dev/null +++ b/app/models/password_reset_token.py @@ -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") diff --git a/app/models/user.py b/app/models/user.py index 0a1d5b3..1cca14b 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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") diff --git a/app/routers/auth.py b/app/routers/auth.py index 5b79c30..c80f7e4 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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) diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..399ffc1 --- /dev/null +++ b/app/schemas/auth.py @@ -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 diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..3578c20 --- /dev/null +++ b/app/services/auth_service.py @@ -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"} diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000..322649e --- /dev/null +++ b/app/services/email_service.py @@ -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""" + + +
+ + + + + ++ Anda menerima email ini karena kami menerima permintaan reset kata sandi untuk akun Anda. +
+ ++ Link ini berlaku selama 1 jam. +
++ Jika Anda tidak meminta reset kata sandi, abaikan email ini. +
++ © 2026 Profitra. Semua hak dilindungi. +
+ + + """ + + params: resend.Emails.SendParams = { + "from": settings.EMAIL_FROM, + "to": [to_email], + "subject": "Reset Kata Sandi - Profitra", + "html": html_content, + } + + resend.Emails.send(params) diff --git a/docs/history/2025-07-22-forgot-password.md b/docs/history/2025-07-22-forgot-password.md new file mode 100644 index 0000000..7ce9784 --- /dev/null +++ b/docs/history/2025-07-22-forgot-password.md @@ -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 diff --git a/requirements.txt b/requirements.txt index eadfe3e..4169d5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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