Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
628 lines
21 KiB
Python
628 lines
21 KiB
Python
"""
|
|
Authentication API endpoints.
|
|
|
|
Endpoints:
|
|
- POST /auth/register - Register new user
|
|
- POST /auth/login - Login and get JWT token
|
|
- GET /auth/me - Get current user info
|
|
- PUT /auth/me - Update current user
|
|
- POST /auth/forgot-password - Request password reset
|
|
- POST /auth/reset-password - Reset password with token
|
|
- POST /auth/verify-email - Verify email address
|
|
- POST /auth/resend-verification - Resend verification email
|
|
"""
|
|
import os
|
|
import secrets
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request, Response
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy import select
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
|
|
from app.api.deps import Database, CurrentUser
|
|
from app.config import get_settings
|
|
from app.schemas.auth import (
|
|
LoginResponse,
|
|
ReferralLinkResponse,
|
|
ReferralStats,
|
|
UserCreate,
|
|
UserLogin,
|
|
UserResponse,
|
|
)
|
|
from app.services.auth import AuthService
|
|
from app.services.email_service import email_service
|
|
from app.models.user import User
|
|
from app.security import set_auth_cookie, clear_auth_cookie
|
|
from app.services.telemetry import track_event
|
|
from app.services.referral_rewards import (
|
|
QUALIFIED_REFERRAL_BATCH_SIZE,
|
|
apply_referral_rewards_for_user,
|
|
compute_badge,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Rate limiter for auth endpoints
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
settings = get_settings()
|
|
|
|
|
|
# ============== Schemas ==============
|
|
|
|
class ForgotPasswordRequest(BaseModel):
|
|
"""Request password reset."""
|
|
email: EmailStr
|
|
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
"""Reset password with token."""
|
|
token: str
|
|
new_password: str
|
|
|
|
|
|
class VerifyEmailRequest(BaseModel):
|
|
"""Verify email with token."""
|
|
token: str
|
|
|
|
|
|
class MessageResponse(BaseModel):
|
|
"""Simple message response."""
|
|
message: str
|
|
success: bool = True
|
|
|
|
|
|
class UpdateUserRequest(BaseModel):
|
|
"""Update user profile."""
|
|
name: Optional[str] = None
|
|
|
|
|
|
# ============== Endpoints ==============
|
|
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
@limiter.limit("5/minute")
|
|
async def register(
|
|
request: Request,
|
|
user_data: UserCreate,
|
|
db: Database,
|
|
background_tasks: BackgroundTasks,
|
|
):
|
|
"""
|
|
Register a new user.
|
|
|
|
- Creates user account
|
|
- Sends verification email (if SMTP configured)
|
|
- Returns user info (without password)
|
|
"""
|
|
# Check if user exists
|
|
existing_user = await AuthService.get_user_by_email(db, user_data.email)
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email already registered",
|
|
)
|
|
|
|
# Create user
|
|
user = await AuthService.create_user(
|
|
db=db,
|
|
email=user_data.email,
|
|
password=user_data.password,
|
|
name=user_data.name,
|
|
)
|
|
|
|
# Process referral if present.
|
|
# Supported formats:
|
|
# - yield_{user_id}_{domain_id}
|
|
# - invite code (12 hex chars)
|
|
referral_applied = False
|
|
referrer_user_id: Optional[int] = None
|
|
referral_type: Optional[str] = None
|
|
|
|
if user_data.ref:
|
|
ref_raw = user_data.ref.strip()
|
|
|
|
# Yield referral: yield_{user_id}_{domain_id}
|
|
if ref_raw.startswith("yield_"):
|
|
try:
|
|
parts = ref_raw.split("_")
|
|
if len(parts) >= 3:
|
|
referrer_user_id = int(parts[1])
|
|
user.referred_by_user_id = referrer_user_id
|
|
user.referral_code = ref_raw
|
|
referral_type = "yield"
|
|
|
|
# Try to map the yield_domain_id to a domain string
|
|
try:
|
|
from app.models.yield_domain import YieldDomain
|
|
|
|
yield_domain_id = int(parts[2])
|
|
yd_res = await db.execute(select(YieldDomain).where(YieldDomain.id == yield_domain_id))
|
|
yd = yd_res.scalar_one_or_none()
|
|
if yd:
|
|
user.referred_by_domain = yd.domain
|
|
except Exception:
|
|
pass
|
|
|
|
await db.commit()
|
|
referral_applied = True
|
|
logger.info("User %s referred via yield by user %s", user.email, referrer_user_id)
|
|
except Exception as e:
|
|
logger.warning("Failed to process yield referral code: %s, error: %s", ref_raw, e)
|
|
else:
|
|
# Invite code referral (viral loop)
|
|
code = ref_raw.lower()
|
|
if re.fullmatch(r"[0-9a-f]{12}", code):
|
|
try:
|
|
ref_user_res = await db.execute(select(User).where(User.invite_code == code))
|
|
ref_user = ref_user_res.scalar_one_or_none()
|
|
if ref_user and ref_user.id != user.id:
|
|
referrer_user_id = ref_user.id
|
|
user.referred_by_user_id = ref_user.id
|
|
user.referral_code = code
|
|
referral_type = "invite"
|
|
await db.commit()
|
|
referral_applied = True
|
|
logger.info("User %s referred via invite_code by user %s", user.email, ref_user.id)
|
|
except Exception as e:
|
|
logger.warning("Failed to process invite referral code: %s, error: %s", code, e)
|
|
|
|
# Auto-admin for specific email
|
|
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
|
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
|
user.is_admin = True
|
|
user.is_verified = True # Auto-verify admins
|
|
await db.commit()
|
|
|
|
# Give admin Tycoon subscription (only if no subscription exists)
|
|
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
|
from sqlalchemy import select
|
|
|
|
# Check if subscription already exists
|
|
existing_sub = await db.execute(
|
|
select(Subscription).where(Subscription.user_id == user.id)
|
|
)
|
|
if not existing_sub.scalar_one_or_none():
|
|
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
|
|
subscription = Subscription(
|
|
user_id=user.id,
|
|
tier=SubscriptionTier.TYCOON,
|
|
status=SubscriptionStatus.ACTIVE,
|
|
max_domains=tycoon_config.get("domain_limit", 500),
|
|
)
|
|
db.add(subscription)
|
|
await db.commit()
|
|
|
|
# Generate verification token
|
|
verification_token = secrets.token_urlsafe(32)
|
|
user.email_verification_token = verification_token
|
|
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
|
|
await db.commit()
|
|
|
|
# Telemetry: registration + referral attribution
|
|
try:
|
|
await track_event(
|
|
db,
|
|
event_name="user_registered",
|
|
request=request,
|
|
user_id=user.id,
|
|
is_authenticated=False,
|
|
source="public",
|
|
metadata={"ref": bool(user_data.ref)},
|
|
)
|
|
if referral_applied:
|
|
await track_event(
|
|
db,
|
|
event_name="referral_attributed",
|
|
request=request,
|
|
user_id=user.id,
|
|
is_authenticated=False,
|
|
source="public",
|
|
metadata={
|
|
"referral_type": referral_type,
|
|
"referrer_user_id": referrer_user_id,
|
|
"ref": user_data.ref,
|
|
},
|
|
)
|
|
await db.commit()
|
|
except Exception:
|
|
# never block registration
|
|
pass
|
|
|
|
# Send verification email in background
|
|
if email_service.is_configured():
|
|
site_url = (settings.site_url or "http://localhost:3000").rstrip("/")
|
|
verify_url = f"{site_url}/verify-email?token={verification_token}"
|
|
|
|
background_tasks.add_task(
|
|
email_service.send_email_verification,
|
|
to_email=user.email,
|
|
user_name=user.name or "there",
|
|
verification_url=verify_url,
|
|
)
|
|
|
|
return user
|
|
|
|
|
|
@router.get("/referral", response_model=ReferralLinkResponse)
|
|
async def get_referral_link(
|
|
request: Request,
|
|
current_user: CurrentUser,
|
|
db: Database,
|
|
days: int = 30,
|
|
):
|
|
"""Return the authenticated user's invite link."""
|
|
if not current_user.invite_code:
|
|
# Generate on demand for older users
|
|
for _ in range(12):
|
|
code = secrets.token_hex(6)
|
|
exists = await db.execute(select(User.id).where(User.invite_code == code))
|
|
if exists.scalar_one_or_none() is None:
|
|
current_user.invite_code = code
|
|
await db.commit()
|
|
break
|
|
if not current_user.invite_code:
|
|
raise HTTPException(status_code=500, detail="Failed to generate invite code")
|
|
|
|
# Apply rewards (idempotent) so UI reflects current state even without scheduler
|
|
snapshot = await apply_referral_rewards_for_user(db, current_user.id)
|
|
await db.commit()
|
|
|
|
base = (settings.site_url or "http://localhost:3000").rstrip("/")
|
|
url = f"{base}/register?ref={current_user.invite_code}"
|
|
|
|
try:
|
|
await track_event(
|
|
db,
|
|
event_name="referral_link_viewed",
|
|
request=request,
|
|
user_id=current_user.id,
|
|
is_authenticated=True,
|
|
source="terminal",
|
|
metadata={"invite_code": current_user.invite_code},
|
|
)
|
|
await db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
# Count link views in the chosen window
|
|
try:
|
|
from datetime import timedelta
|
|
from sqlalchemy import and_, func
|
|
|
|
from app.models.telemetry import TelemetryEvent
|
|
|
|
window_days = max(1, min(int(days), 365))
|
|
end = datetime.utcnow()
|
|
start = end - timedelta(days=window_days)
|
|
views = (
|
|
await db.execute(
|
|
select(func.count(TelemetryEvent.id)).where(
|
|
and_(
|
|
TelemetryEvent.event_name == "referral_link_viewed",
|
|
TelemetryEvent.user_id == current_user.id,
|
|
TelemetryEvent.created_at >= start,
|
|
TelemetryEvent.created_at <= end,
|
|
)
|
|
)
|
|
)
|
|
).scalar()
|
|
referral_link_views_window = int(views or 0)
|
|
except Exception:
|
|
window_days = 30
|
|
referral_link_views_window = 0
|
|
|
|
qualified = int(snapshot.qualified_referrals_total)
|
|
if qualified < QUALIFIED_REFERRAL_BATCH_SIZE:
|
|
next_reward_at = QUALIFIED_REFERRAL_BATCH_SIZE
|
|
else:
|
|
remainder = qualified % QUALIFIED_REFERRAL_BATCH_SIZE
|
|
next_reward_at = qualified + (QUALIFIED_REFERRAL_BATCH_SIZE - remainder) if remainder else qualified + QUALIFIED_REFERRAL_BATCH_SIZE
|
|
|
|
return ReferralLinkResponse(
|
|
invite_code=current_user.invite_code,
|
|
url=url,
|
|
stats=ReferralStats(
|
|
window_days=int(window_days),
|
|
referred_users_total=int(snapshot.referred_users_total),
|
|
qualified_referrals_total=qualified,
|
|
referral_link_views_window=int(referral_link_views_window),
|
|
bonus_domains=int(snapshot.bonus_domains),
|
|
next_reward_at=int(next_reward_at),
|
|
badge=compute_badge(qualified),
|
|
cooldown_days=int(getattr(snapshot, "cooldown_days", 7) or 7),
|
|
disqualified_cooldown_total=int(getattr(snapshot, "disqualified_cooldown_total", 0) or 0),
|
|
disqualified_missing_ip_total=int(getattr(snapshot, "disqualified_missing_ip_total", 0) or 0),
|
|
disqualified_shared_ip_total=int(getattr(snapshot, "disqualified_shared_ip_total", 0) or 0),
|
|
disqualified_duplicate_ip_total=int(getattr(snapshot, "disqualified_duplicate_ip_total", 0) or 0),
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
@limiter.limit("10/minute")
|
|
async def login(request: Request, user_data: UserLogin, db: Database, response: Response):
|
|
"""
|
|
Authenticate user and return JWT token.
|
|
|
|
Note: Email verification is currently not enforced.
|
|
Set REQUIRE_EMAIL_VERIFICATION=true to enforce.
|
|
"""
|
|
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
|
from sqlalchemy import select
|
|
|
|
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect email or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
# Auto-admin for specific email
|
|
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
|
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
|
if not user.is_admin:
|
|
user.is_admin = True
|
|
user.is_verified = True # Auto-verify admins
|
|
await db.commit()
|
|
|
|
# Ensure admin has Tycoon subscription
|
|
sub_result = await db.execute(
|
|
select(Subscription).where(Subscription.user_id == user.id)
|
|
)
|
|
subscription = sub_result.scalar_one_or_none()
|
|
|
|
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
|
|
|
|
if not subscription:
|
|
subscription = Subscription(
|
|
user_id=user.id,
|
|
tier=SubscriptionTier.TYCOON,
|
|
status=SubscriptionStatus.ACTIVE,
|
|
max_domains=tycoon_config.get("domain_limit", 500),
|
|
)
|
|
db.add(subscription)
|
|
await db.commit()
|
|
elif subscription.tier != SubscriptionTier.TYCOON:
|
|
subscription.tier = SubscriptionTier.TYCOON
|
|
subscription.max_domains = tycoon_config.get("domain_limit", 500)
|
|
subscription.status = SubscriptionStatus.ACTIVE
|
|
await db.commit()
|
|
|
|
# Optional: Check email verification
|
|
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
|
|
if require_verification and not user.is_verified:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Please verify your email address before logging in",
|
|
)
|
|
|
|
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
|
access_token = AuthService.create_access_token(
|
|
data={"sub": str(user.id), "email": user.email},
|
|
expires_delta=access_token_expires,
|
|
)
|
|
|
|
# Set HttpOnly cookie (preferred for browser clients)
|
|
set_auth_cookie(
|
|
response=response,
|
|
token=access_token,
|
|
max_age_seconds=settings.access_token_expire_minutes * 60,
|
|
)
|
|
|
|
# Do NOT return the token in the response body (prevents leaks via logs/JS storage)
|
|
return LoginResponse(expires_in=settings.access_token_expire_minutes * 60)
|
|
|
|
|
|
@router.post("/logout", response_model=MessageResponse)
|
|
async def logout(response: Response):
|
|
"""Clear auth cookie."""
|
|
clear_auth_cookie(response)
|
|
return MessageResponse(message="Logged out")
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_current_user_info(current_user: CurrentUser):
|
|
"""Get current user information."""
|
|
return current_user
|
|
|
|
|
|
@router.put("/me", response_model=UserResponse)
|
|
async def update_current_user(
|
|
update_data: UpdateUserRequest,
|
|
current_user: CurrentUser,
|
|
db: Database,
|
|
):
|
|
"""Update current user information."""
|
|
if update_data.name is not None:
|
|
current_user.name = update_data.name
|
|
|
|
await db.commit()
|
|
await db.refresh(current_user)
|
|
|
|
return current_user
|
|
|
|
|
|
@router.post("/forgot-password", response_model=MessageResponse)
|
|
@limiter.limit("3/minute")
|
|
async def forgot_password(
|
|
request: Request,
|
|
payload: ForgotPasswordRequest,
|
|
db: Database,
|
|
background_tasks: BackgroundTasks,
|
|
):
|
|
"""
|
|
Request password reset email.
|
|
|
|
- Always returns success (to prevent email enumeration)
|
|
- If email exists, sends reset link
|
|
- Reset token expires in 1 hour
|
|
"""
|
|
# Always return success (security: don't reveal if email exists)
|
|
success_message = "If an account with this email exists, a password reset link has been sent."
|
|
|
|
# Look up user
|
|
result = await db.execute(select(User).where(User.email == payload.email.lower()))
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
# Return success anyway (security)
|
|
return MessageResponse(message=success_message)
|
|
|
|
# Generate reset token
|
|
reset_token = secrets.token_urlsafe(32)
|
|
user.password_reset_token = reset_token
|
|
user.password_reset_expires = datetime.utcnow() + timedelta(hours=1)
|
|
await db.commit()
|
|
|
|
# Send reset email in background
|
|
if email_service.is_configured():
|
|
site_url = (settings.site_url or "http://localhost:3000").rstrip("/")
|
|
reset_url = f"{site_url}/reset-password?token={reset_token}"
|
|
|
|
background_tasks.add_task(
|
|
email_service.send_password_reset,
|
|
to_email=user.email,
|
|
user_name=user.name or "there",
|
|
reset_url=reset_url,
|
|
)
|
|
logger.info(f"Password reset email queued for {user.email}")
|
|
else:
|
|
logger.warning(f"SMTP not configured, cannot send reset email for {user.email}")
|
|
|
|
return MessageResponse(message=success_message)
|
|
|
|
|
|
@router.post("/reset-password", response_model=MessageResponse)
|
|
async def reset_password(
|
|
request: ResetPasswordRequest,
|
|
db: Database,
|
|
):
|
|
"""
|
|
Reset password using token from email.
|
|
|
|
- Token must be valid and not expired
|
|
- Password must be at least 8 characters
|
|
- Invalidates token after use
|
|
"""
|
|
if len(request.new_password) < 8:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Password must be at least 8 characters",
|
|
)
|
|
|
|
# Find user with valid token
|
|
result = await db.execute(
|
|
select(User).where(
|
|
User.password_reset_token == request.token,
|
|
User.password_reset_expires > datetime.utcnow(),
|
|
)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired reset token",
|
|
)
|
|
|
|
# Update password
|
|
user.password_hash = AuthService.get_password_hash(request.new_password)
|
|
user.password_reset_token = None
|
|
user.password_reset_expires = None
|
|
await db.commit()
|
|
|
|
logger.info(f"Password reset successful for user {user.id}")
|
|
|
|
return MessageResponse(message="Password has been reset successfully. You can now log in.")
|
|
|
|
|
|
@router.post("/verify-email", response_model=MessageResponse)
|
|
async def verify_email(
|
|
request: VerifyEmailRequest,
|
|
db: Database,
|
|
):
|
|
"""
|
|
Verify email address using token from email.
|
|
|
|
- Token must be valid and not expired
|
|
- Marks user as verified
|
|
"""
|
|
# Find user with valid token
|
|
result = await db.execute(
|
|
select(User).where(
|
|
User.email_verification_token == request.token,
|
|
User.email_verification_expires > datetime.utcnow(),
|
|
)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired verification token",
|
|
)
|
|
|
|
# Mark as verified
|
|
user.is_verified = True
|
|
user.email_verification_token = None
|
|
user.email_verification_expires = None
|
|
await db.commit()
|
|
|
|
logger.info(f"Email verified for user {user.id}")
|
|
|
|
return MessageResponse(message="Email verified successfully. You can now log in.")
|
|
|
|
|
|
@router.post("/resend-verification", response_model=MessageResponse)
|
|
@limiter.limit("3/minute")
|
|
async def resend_verification(
|
|
request: Request,
|
|
payload: ForgotPasswordRequest, # Reuse schema - just needs email
|
|
db: Database,
|
|
background_tasks: BackgroundTasks,
|
|
):
|
|
"""
|
|
Resend verification email.
|
|
|
|
- Rate limited to prevent abuse
|
|
- Always returns success (security)
|
|
"""
|
|
success_message = "If an unverified account with this email exists, a verification link has been sent."
|
|
|
|
# Look up user
|
|
result = await db.execute(
|
|
select(User).where(User.email == payload.email.lower())
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user or user.is_verified:
|
|
return MessageResponse(message=success_message)
|
|
|
|
# Generate new verification token
|
|
verification_token = secrets.token_urlsafe(32)
|
|
user.email_verification_token = verification_token
|
|
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
|
|
await db.commit()
|
|
|
|
# Send verification email
|
|
if email_service.is_configured():
|
|
site_url = (settings.site_url or "http://localhost:3000").rstrip("/")
|
|
verify_url = f"{site_url}/verify-email?token={verification_token}"
|
|
|
|
background_tasks.add_task(
|
|
email_service.send_email_verification,
|
|
to_email=user.email,
|
|
user_name=user.name or "there",
|
|
verification_url=verify_url,
|
|
)
|
|
|
|
return MessageResponse(message=success_message)
|