velxio/backend/app/api/routes/auth.py

393 lines
14 KiB
Python

import asyncio
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dependencies import get_current_user, require_auth
from app.core.security import create_access_token, hash_password, verify_password
from app.database.session import get_db
from app.models.password_reset_token import PasswordResetToken
from app.models.user import User
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
RegisterRequest,
ResetPasswordRequest,
UserResponse,
)
from app.services import odoo_mail
from app.utils.geo import country_from_request
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Password reset helpers ────────────────────────────────────────────────
def _hash_token(plaintext: str) -> str:
"""SHA-256 hex of the token. Cheap one-shot hash (collision-free in
practice for 32-byte URL-safe inputs); we don't need bcrypt here
because the input itself is high-entropy random."""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _ensure_aware(value: datetime) -> datetime:
"""SQLite (default DB) round-trips datetimes as naive — tag them UTC
so comparisons with `_now_utc()` don't raise."""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
secure=settings.COOKIE_SECURE,
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
body: RegisterRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
# Check uniqueness
existing = await db.execute(
select(User).where((User.email == body.email) | (User.username == body.username))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email or username already taken.")
country = country_from_request(request)
user = User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
signup_country=country,
last_country=country,
)
db.add(user)
await db.commit()
await db.refresh(user)
token = create_access_token({"sub": user.id})
_set_auth_cookie(response, token)
# Synchronously upsert the Odoo partner BEFORE firing the async
# welcome. This serializes partner creation at the Velxio HTTP
# layer so a subsequent forgot-password (or any other mail) lands
# on a partner whose existence is already committed — sidestepping
# the REPEATABLE-READ snapshot race two parallel mail workers
# would otherwise hit. `sync_partner` swallows its own errors and
# returns None if Odoo is down, so the user is never blocked.
await odoo_mail.sync_partner(
velxio_user_id=user.id,
email=user.email,
name=user.username,
country_code=user.signup_country or None,
)
asyncio.create_task(
odoo_mail.send_welcome(
velxio_user_id=user.id,
email=user.email,
name=user.username,
country_code=user.signup_country or None,
editor_url=f"{settings.FRONTEND_URL.rstrip('/')}/editor",
examples_url=f"{settings.FRONTEND_URL.rstrip('/')}/examples",
)
)
return user
@router.post("/login", response_model=UserResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not user.hashed_password or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials.")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled.")
country = country_from_request(request)
if country:
user.last_country = country
await db.commit()
token = create_access_token({"sub": user.id})
_set_auth_cookie(response, token)
return user
@router.get("/me", response_model=UserResponse)
async def me(user: User = Depends(get_current_user)):
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated.")
return user
@router.post("/logout")
async def logout(response: Response, _user: User = Depends(require_auth)):
response.delete_cookie("access_token")
return {"message": "Logged out."}
# ── Password reset flow ───────────────────────────────────────────────────
_GENERIC_FORGOT_REPLY = {
"message": "If that email is registered, a reset link is on its way.",
}
@router.post("/forgot-password")
async def forgot_password(
body: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""Send a password-reset email if the address is registered.
Always returns 200 with the same generic message so a stranger can't
enumerate which emails belong to Velxio accounts. Rate-limited: at
most N=PASSWORD_RESET_RATE_LIMIT_PER_HOUR fresh tokens per user per
rolling hour. Excess attempts succeed silently (same generic 200) but
do NOT generate a token or email.
Tokens are 32-byte URL-safe random strings; only their SHA-256 hash
is persisted. The plaintext only leaves the server inside the reset
URL emailed via Odoo.
"""
email = body.email.lower()
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not user.is_active:
# Anti-enumeration: identical response shape and roughly identical
# latency. We don't sleep to fake the per-user code path — the
# bcrypt + token round-trips below are dominated by Odoo's
# network call, which only fires for real users anyway.
return _GENERIC_FORGOT_REPLY
# Rate-limit: count tokens minted in the last hour for this user.
window_start = _now_utc() - timedelta(hours=1)
count_result = await db.execute(
select(func.count(PasswordResetToken.id)).where(
PasswordResetToken.user_id == user.id,
PasswordResetToken.created_at >= window_start,
)
)
recent = count_result.scalar_one() or 0
if recent >= settings.PASSWORD_RESET_RATE_LIMIT_PER_HOUR:
logger.warning(
"[forgot-password] rate-limited user=%s email=%s recent=%s",
user.id, email, recent,
)
return _GENERIC_FORGOT_REPLY
# Mint a fresh one-time token; store only the hash.
plaintext = secrets.token_urlsafe(32)
token_row = PasswordResetToken(
user_id=user.id,
token_hash=_hash_token(plaintext),
expires_at=_now_utc() + timedelta(
minutes=settings.PASSWORD_RESET_TOKEN_TTL_MINUTES,
),
)
db.add(token_row)
await db.commit()
reset_url = (
f"{settings.FRONTEND_URL.rstrip('/')}/reset-password?token={plaintext}"
)
# Same trick as register: ensure the Odoo partner exists synchronously
# before firing the async reset mail. Cheap (~50ms) and removes the
# REPEATABLE-READ snapshot race entirely.
await odoo_mail.sync_partner(
velxio_user_id=user.id,
email=user.email,
name=user.username,
country_code=user.signup_country or None,
)
asyncio.create_task(
odoo_mail.send_password_reset(
email=user.email,
reset_url=reset_url,
expires_in_minutes=settings.PASSWORD_RESET_TOKEN_TTL_MINUTES,
user_name=user.username,
velxio_user_id=user.id,
)
)
logger.info("[forgot-password] token minted user=%s", user.id)
return _GENERIC_FORGOT_REPLY
@router.post("/reset-password")
async def reset_password(
body: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""Consume a one-time token and set the user's new password.
Returns 400 for: token unknown, expired, or already used. We don't
distinguish those cases in the response — keeps probing useless — but
the server log records the exact reason.
"""
token_hash = _hash_token(body.token)
result = await db.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
)
token_row = result.scalar_one_or_none()
if not token_row:
logger.info("[reset-password] unknown token hash=%s", token_hash[:8])
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
expires_at = _ensure_aware(token_row.expires_at)
if expires_at < _now_utc():
logger.info("[reset-password] expired token id=%s", token_row.id)
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
if token_row.used_at is not None:
logger.info("[reset-password] reused token id=%s", token_row.id)
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
user_result = await db.execute(select(User).where(User.id == token_row.user_id))
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="Reset link is invalid or has expired.")
user.hashed_password = hash_password(body.new_password)
token_row.used_at = _now_utc()
await db.commit()
logger.info("[reset-password] consumed token id=%s user=%s", token_row.id, user.id)
return {"message": "Password has been reset. You can now sign in with your new password."}
# ── Google OAuth ──────────────────────────────────────────────────────────────
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
@router.get("/google")
async def google_login():
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(status_code=501, detail="Google OAuth not configured.")
params = {
"client_id": settings.GOOGLE_CLIENT_ID,
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
}
from urllib.parse import urlencode
url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
return RedirectResponse(url)
@router.get("/google/callback")
async def google_callback(
code: str,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(status_code=501, detail="Google OAuth not configured.")
async with httpx.AsyncClient() as client:
token_resp = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_resp.raise_for_status()
access_token = token_resp.json()["access_token"]
userinfo_resp = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
userinfo_resp.raise_for_status()
userinfo = userinfo_resp.json()
google_id: str = userinfo["sub"]
email: str = userinfo.get("email", "")
avatar_url: str | None = userinfo.get("picture")
country = country_from_request(request)
# Upsert user by google_id
result = await db.execute(select(User).where(User.google_id == google_id))
user = result.scalar_one_or_none()
if not user:
# Try to find by email (link accounts)
result2 = await db.execute(select(User).where(User.email == email))
user = result2.scalar_one_or_none()
if user:
user.google_id = google_id
if avatar_url and not user.avatar_url:
user.avatar_url = avatar_url
else:
# Generate username from email prefix
base_username = email.split("@")[0].lower()
import re
base_username = re.sub(r"[^a-z0-9_-]", "-", base_username)[:28]
username = base_username
counter = 1
while True:
existing = await db.execute(select(User).where(User.username == username))
if not existing.scalar_one_or_none():
break
username = f"{base_username}{counter}"
counter += 1
user = User(
username=username,
email=email,
google_id=google_id,
avatar_url=avatar_url,
signup_country=country,
last_country=country,
)
db.add(user)
if country:
user.last_country = country
await db.commit()
await db.refresh(user)
jwt_token = create_access_token({"sub": user.id}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
# Send the user straight to the editor after OAuth login
redirect = RedirectResponse(url=f"{settings.FRONTEND_URL}/editor")
_set_auth_cookie(redirect, jwt_token)
return redirect