Implement email verification flow: add EmailVerificationToken model, update user registration and login status handling, and create verification endpoints
This commit is contained in:
parent
0355781852
commit
14c35d2bde
40
app/dependencies.py
Normal file
40
app/dependencies.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
21
app/models/email_verification_token.py
Normal file
21
app/models/email_verification_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 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")
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;">
|
||||
© 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
|
||||
|
||||
|
||||
71
docs/history/2026-07-25-user-status-flow.md
Normal file
71
docs/history/2026-07-25-user-status-flow.md
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user