Implement email verification flow: add EmailVerificationToken model, update user registration and login status handling, and create verification endpoints

This commit is contained in:
Yoga Pangestu 2026-07-25 13:48:00 +07:00
parent 0355781852
commit 14c35d2bde
10 changed files with 351 additions and 7 deletions

40
app/dependencies.py Normal file
View File

@ -0,0 +1,40 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.security import decode_access_token
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Token tidak valid."},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Token tidak valid."},
)
user = db.query(User).filter(
User.id == user_id,
User.deleted_at.is_(None),
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Pengguna tidak ditemukan."},
)
return user

View File

@ -1,6 +1,7 @@
from app.models.user import User
from app.models.user_profile import UserProfile
from app.models.password_reset_token import PasswordResetToken
from app.models.email_verification_token import EmailVerificationToken
from app.models.business_type import BusinessType
from app.models.plan import Plan
from app.models.tenant import Tenant
@ -10,6 +11,7 @@ __all__ = [
"User",
"UserProfile",
"PasswordResetToken",
"EmailVerificationToken",
"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 EmailVerificationToken(Base):
__tablename__ = "email_verification_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="email_verification_tokens")

View File

@ -17,10 +17,11 @@ class User(Base):
password: Mapped[str] = mapped_column(String(255))
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(CHAR(36), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="ONBOARDING")
status: Mapped[str] = mapped_column(String(20), default="INACTIVE")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=datetime.now)
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")
email_verification_tokens: Mapped[list["EmailVerificationToken"]] = relationship(back_populates="user")

View File

@ -1,6 +1,7 @@
import random
import secrets
import string
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from google.oauth2 import id_token
@ -9,22 +10,35 @@ from sqlalchemy.orm import Session, selectinload
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user
from app.models.email_verification_token import EmailVerificationToken
from app.models.user import User
from app.models.user_profile import UserProfile
from app.schemas.auth import ForgotPasswordRequest, ResetPasswordRequest
from app.schemas.auth import (
ForgotPasswordRequest,
ResetPasswordRequest,
SendVerificationRequest,
VerifyEmailRequest,
)
from app.schemas.user import (
GoogleLoginRequest,
LoginRequest,
LoginResponse,
UserCreate,
UserDetailResponse,
UserResponse,
)
from app.security import create_access_token, hash_password, verify_password
from app.services.auth_service import forgot_password, reset_password
from app.services.email_service import send_verification_email
router = APIRouter(prefix="/auth", tags=["auth"])
def _generate_verification_token() -> str:
return secrets.token_urlsafe(48)
@router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).options(selectinload(User.profile)).filter(
@ -66,6 +80,7 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
email=data.email,
username=data.username,
password=hash_password(data.password),
status="INACTIVE",
)
db.add(user)
db.flush()
@ -85,11 +100,26 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
profile_data["dob"] = data.dob
profile = UserProfile(**profile_data)
db.add(profile)
raw_token = _generate_verification_token()
hashed_token = hash_password(raw_token)
verif_token = EmailVerificationToken(
user_id=user.id,
token=hashed_token,
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
)
db.add(verif_token)
db.commit()
except:
db.rollback()
raise
verify_url = f"{settings.FRONTEND_URL}/auth/verify-email?token={raw_token}"
try:
send_verification_email(user.email, verify_url)
except Exception:
pass
db.refresh(user)
return user
@ -110,7 +140,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
email = idinfo.get("email")
name = idinfo.get("name", "")
picture = idinfo.get("picture", "")
if not email:
raise HTTPException(
@ -138,7 +167,7 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
username=username,
password=hash_password(random_password),
email_verified_at=datetime.now(timezone.utc),
status="ACTIVE",
status="ONBOARDING",
)
db.add(user)
db.flush()
@ -162,6 +191,8 @@ 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,
@ -169,6 +200,119 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
)
@router.post("/verify-email")
def verify_email(data: VerifyEmailRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc)
tokens = db.query(EmailVerificationToken).filter(
EmailVerificationToken.used_at.is_(None),
).all()
matched = None
for t in tokens:
if verify_password(data.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.email_verified_at = now
user.status = "ONBOARDING"
db.query(EmailVerificationToken).filter(
EmailVerificationToken.user_id == user.id,
EmailVerificationToken.used_at.is_(None),
).update({"used_at": now})
db.commit()
return {"message": "Email berhasil diverifikasi. Silakan masuk ke akun Anda."}
@router.post("/send-verification")
def send_verification(data: SendVerificationRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(
User.email == data.email,
User.deleted_at.is_(None),
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"message": "Email tidak ditemukan."},
)
if user.email_verified_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"message": "Email sudah diverifikasi."},
)
now = datetime.now(timezone.utc)
active_token = db.query(EmailVerificationToken).filter(
EmailVerificationToken.user_id == user.id,
EmailVerificationToken.used_at.is_(None),
EmailVerificationToken.expires_at > now,
).first()
if active_token:
return {"message": "Link verifikasi sudah dikirim ke email Anda dan masih aktif."}
db.query(EmailVerificationToken).filter(
EmailVerificationToken.user_id == user.id,
EmailVerificationToken.used_at.is_(None),
).update({"used_at": now})
raw_token = _generate_verification_token()
hashed_token = hash_password(raw_token)
verif_token = EmailVerificationToken(
user_id=user.id,
token=hashed_token,
expires_at=now + timedelta(hours=24),
)
db.add(verif_token)
db.commit()
verify_url = f"{settings.FRONTEND_URL}/auth/verify-email?token={raw_token}"
try:
send_verification_email(user.email, verify_url)
except Exception:
pass
return {"message": "Link verifikasi telah dikirim ke email Anda."}
@router.get("/me", response_model=UserDetailResponse)
def get_me(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
user = db.query(User).options(selectinload(User.profile)).filter(
User.id == current_user.id
).first()
return user
@router.post("/forgot-password")
def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)):
return forgot_password(db, data.email)

View File

@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.responses import created_response
from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate
from app.services.tenant_service import (
@ -28,9 +30,13 @@ def get_tenant(id: str, db: Session = Depends(get_db)):
@router.post("/", status_code=status.HTTP_201_CREATED)
def create_tenant_route(
request: Request, data: TenantCreate, db: Session = Depends(get_db)
request: Request,
data: TenantCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant = create_tenant(db, data)
assign_user_to_tenant(db, tenant.id, current_user.id)
location = f"{request.url.path}{tenant.id}"
return created_response(location)

View File

@ -1,6 +1,14 @@
from pydantic import BaseModel, EmailStr, field_validator
class VerifyEmailRequest(BaseModel):
token: str
class SendVerificationRequest(BaseModel):
email: EmailStr
class ForgotPasswordRequest(BaseModel):
email: EmailStr

View File

@ -7,7 +7,6 @@ class UserCreate(BaseModel):
email: EmailStr
username: str
password: str
status: str = "ONBOARDING"
full_name: str
phone: str | None = None
address: str | None = None

View File

@ -3,6 +3,58 @@ import resend
from app.config import settings
def send_verification_email(to_email: str, verify_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;">Verifikasi Email</h1>
<p style="font-size: 14px; color: #71717a; margin: 0 0 24px; line-height: 1.6; text-align: center;">
Klik tombol di bawah untuk memverifikasi alamat email Anda dan mulai menggunakan Profitra.
</p>
<div style="text-align: center; margin: 0 0 24px;">
<a href="{verify_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;">
Verifikasi Email
</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>24 jam</strong>.
</p>
<p style="font-size: 13px; color: #a1a1aa; margin: 0; line-height: 1.6; text-align: center;">
Jika Anda tidak mendaftar akun Profitra, 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": "Verifikasi Email - Profitra",
"html": html_content,
}
resend.Emails.send(params)
def send_password_reset_email(to_email: str, reset_url: str) -> None:
resend.api_key = settings.RESEND_API_KEY

View File

@ -0,0 +1,71 @@
# Perubahan Flow Status User
**Tanggal:** 2026-07-25
**Status:** Selesai
## Tujuan
Menerapkan flow status user yang baru:
- Register via Google → status `ONBOARDING`
- Register manual → status `INACTIVE` → verifikasi email → status `ONBOARDING`
- Setelah isi onboarding → status `ACTIVE`
## Yang Dikerjakan
### 1. Ubah Default Status User di Model
- **Sebelum:** `default="ONBOARDING"`
- **Sesudah:** `default="INACTIVE"`
### 2. Hapus Field Status dari UserCreate Schema
- Status tidak perlu dikirim dari frontend, diatur oleh backend
### 3. Ubah Status di Register Endpoint
- `POST /auth/register`: Set status `INACTIVE` secara eksplisit
- Generate dan kirim verification token + email
### 4. Ubah Status di Google Login Endpoint
- `POST /auth/google-login`:
- **Sebelum:** `status="ACTIVE"`
- **Sesudah:** `status="ONBOARDING"` (user baru)
- Existing user tidak diubah statusnya
### 5. Buat EmailVerificationToken Model
- Model baru: `EmailVerificationToken`
- Relasi dengan User
- Expires 24 jam
### 6. Tambah Fungsi Email Verifikasi
- `send_verification_email()` di `email_service.py`
- Template email verifikasi
### 7. Tambah Endpoint Verifikasi Email
- `POST /auth/verify-email` — verifikasi token, set `email_verified_at` dan `status=ONBOARDING`
- `POST /auth/send-verification` — kirim ulang link verifikasi
- `GET /auth/me` — get current user (dengan profile)
### 8. Auth Dependency
- `dependencies.py``get_current_user()` untuk proteksi endpoint
### 9. Auto-Assign User saat Create Tenant
- `POST /tenants` sekarang require auth
- Setelah create tenant, auto-assign user ke tenant dan set status `ACTIVE`
## File yang Diubah
| File | Aksi | Detail |
|------|------|--------|
| `app/models/user.py` | Diubah | Default status `ONBOARDING``INACTIVE` |
| `app/models/email_verification_token.py` | Dibuat | Model token verifikasi email |
| `app/models/__init__.py` | Diubah | Export EmailVerificationToken |
| `app/schemas/user.py` | Diubah | Hapus status dari UserCreate |
| `app/schemas/auth.py` | Diubah | Tambah VerifyEmailRequest, SendVerificationRequest |
| `app/services/email_service.py` | Diubah | Tambah send_verification_email() |
| `app/dependencies.py` | Dibuat | Auth dependency get_current_user |
| `app/routers/auth.py` | Diubah | Status INACTIVE di register, ONBOARDING di google, tambah verify/send-verification/me |
| `app/routers/tenants.py` | Diubah | Auth dependency di create tenant, auto-assign user |
| `app/services/tenant_service.py` | Tidak diubah | (assign_user_to_tenant sudah ada) |
## Notes
- Admin seeder tetap menggunakan ACTIVE untuk admin user
- UserFactory tetap menggunakan ACTIVE untuk test data
- Email verifikasi dikirim via Resend API (sama dengan forgot password)
- Jika gagal kirim email, register tetap berhasil (error email diabaikan dengan try/except)