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 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.password_reset_token import PasswordResetToken
from app.models.email_verification_token import EmailVerificationToken
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
@ -10,6 +11,7 @@ __all__ = [
"User", "User",
"UserProfile", "UserProfile",
"PasswordResetToken", "PasswordResetToken",
"EmailVerificationToken",
"BusinessType", "BusinessType",
"Plan", "Plan",
"Tenant", "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)) password: Mapped[str] = mapped_column(String(255))
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) email_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(CHAR(36), 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=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) 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") 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 random
import secrets
import string import string
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from google.oauth2 import id_token from google.oauth2 import id_token
@ -9,22 +10,35 @@ from sqlalchemy.orm import Session, selectinload
from app.config import settings from app.config import settings
from app.database import get_db 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 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.auth import (
ForgotPasswordRequest,
ResetPasswordRequest,
SendVerificationRequest,
VerifyEmailRequest,
)
from app.schemas.user import ( from app.schemas.user import (
GoogleLoginRequest, GoogleLoginRequest,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
UserCreate, UserCreate,
UserDetailResponse,
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 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"]) router = APIRouter(prefix="/auth", tags=["auth"])
def _generate_verification_token() -> str:
return secrets.token_urlsafe(48)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)): def login(data: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).options(selectinload(User.profile)).filter( 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, email=data.email,
username=data.username, username=data.username,
password=hash_password(data.password), password=hash_password(data.password),
status="INACTIVE",
) )
db.add(user) db.add(user)
db.flush() db.flush()
@ -85,11 +100,26 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
profile_data["dob"] = data.dob profile_data["dob"] = data.dob
profile = UserProfile(**profile_data) profile = UserProfile(**profile_data)
db.add(profile) 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() db.commit()
except: except:
db.rollback() db.rollback()
raise 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) db.refresh(user)
return user return user
@ -110,7 +140,6 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
email = idinfo.get("email") email = idinfo.get("email")
name = idinfo.get("name", "") name = idinfo.get("name", "")
picture = idinfo.get("picture", "")
if not email: if not email:
raise HTTPException( raise HTTPException(
@ -138,7 +167,7 @@ def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)):
username=username, username=username,
password=hash_password(random_password), password=hash_password(random_password),
email_verified_at=datetime.now(timezone.utc), email_verified_at=datetime.now(timezone.utc),
status="ACTIVE", status="ONBOARDING",
) )
db.add(user) db.add(user)
db.flush() db.flush()
@ -162,6 +191,8 @@ 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,
@ -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") @router.post("/forgot-password")
def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)): def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)):
return forgot_password(db, data.email) 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 sqlalchemy.orm import Session
from app.database import get_db 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.responses import created_response
from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate from app.schemas.tenant import TenantCreate, TenantResponse, TenantUpdate
from app.services.tenant_service import ( 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) @router.post("/", status_code=status.HTTP_201_CREATED)
def create_tenant_route( 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) tenant = create_tenant(db, data)
assign_user_to_tenant(db, tenant.id, current_user.id)
location = f"{request.url.path}{tenant.id}" location = f"{request.url.path}{tenant.id}"
return created_response(location) return created_response(location)

View File

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

View File

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

View File

@ -3,6 +3,58 @@ import resend
from app.config import settings 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: def send_password_reset_email(to_email: str, reset_url: str) -> None:
resend.api_key = settings.RESEND_API_KEY 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)