Files
gh-christianlouis-docuelevate/app/api/local_auth.py
T
copilot-swe-agent[bot] b5b285ebe6 fix: gate LocalUser machinery on multi_user_enabled for single-user backward compat
- auth() only queries LocalUser table when multi_user_enabled=True
- login() only shows signup link when multi_user_enabled AND allow_local_signup
- signup page and POST endpoint both check multi_user_enabled first
- Move local-auth imports to module level in auth.py (no re-import overhead)
- Fix signup rollback: flush before email send, commit only on success
- Update allow_local_signup config description to document prerequisite
- Add test: single-user mode skips LocalUser table entirely
- Patch multi_user_enabled=True on all local-login integration tests

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-07 13:35:10 +00:00

322 lines
12 KiB
Python

"""Local user authentication API — signup, email verification, password reset.
Provides the REST endpoints and page routes for the self-registration flow:
- GET /signup — signup page (HTML)
- POST /api/auth/signup — create account + send verification email
- GET /verify-email — activate account from email link (redirect)
- GET /verify-email-sent — confirmation landing page (HTML)
- POST /api/auth/resend-verification — re-send verification email
- POST /api/auth/request-password-reset — start password reset
- POST /api/auth/reset-password — set new password using token
- GET /reset-password — password reset form page (HTML)
"""
import logging
import pathlib
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
from app.config import settings
from app.database import get_db
from app.models import LocalUser, UserProfile
from app.utils.local_auth import (
build_session_user,
generate_token,
hash_password,
is_token_expired,
send_password_reset_email,
send_verification_email,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["local-auth"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
templates = Jinja2Templates(directory=str(_templates_dir))
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class SignupBody(BaseModel):
"""Body for the signup endpoint."""
email: str = Field(..., max_length=255)
username: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
display_name: str | None = Field(default=None, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
password_confirm: str
class ResendVerificationBody(BaseModel):
"""Body for the resend-verification endpoint."""
email: str
class PasswordResetRequestBody(BaseModel):
"""Body for the request-password-reset endpoint."""
email: str
class PasswordResetBody(BaseModel):
"""Body for the reset-password endpoint."""
token: str
new_password: str = Field(..., min_length=8, max_length=128)
new_password_confirm: str
# ---------------------------------------------------------------------------
# Page routes (return HTML)
# ---------------------------------------------------------------------------
@router.get("/signup", include_in_schema=False)
async def signup_page(request: Request) -> Any:
"""Render the signup page, or redirect to login when multi-user / signup is disabled."""
if not settings.multi_user_enabled:
return RedirectResponse(url="/login?error=Multi-user+mode+is+not+enabled", status_code=302)
if not settings.allow_local_signup:
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
return templates.TemplateResponse(
"signup.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/verify-email-sent", include_in_schema=False)
async def verify_email_sent_page(request: Request) -> Any:
"""Render the verify-email-sent confirmation page."""
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
@router.get("/reset-password", include_in_schema=False)
async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
token = request.query_params.get("token", "")
return templates.TemplateResponse(
"password_reset_form.html",
{
"request": request,
"token": token,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
# ---------------------------------------------------------------------------
# API endpoints (return JSON or redirect)
# ---------------------------------------------------------------------------
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
"""Create a new local user account and send a verification email.
The account is inactive until the user clicks the email link.
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
Raises:
403: Multi-user mode or local signup is disabled.
503: SMTP is not configured.
422: Passwords do not match.
409: Email or username already registered.
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
if not settings.allow_local_signup:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
if not settings.email_host:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email (SMTP) must be configured before local signup can be enabled.",
)
if body.password != body.password_confirm:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
if db.query(LocalUser).filter(LocalUser.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
token = generate_token()
user = LocalUser(
email=body.email,
username=body.username,
display_name=body.display_name,
hashed_password=hash_password(body.password),
is_active=False,
email_verification_token=token,
email_verification_sent_at=datetime.now(tz=timezone.utc),
)
db.add(user)
profile = UserProfile(
user_id=body.email,
display_name=body.display_name or body.username,
)
db.add(profile)
# Flush to the DB so constraint violations (duplicate key etc.) surface NOW,
# before we attempt to send the email. We do NOT commit yet — the commit only
# happens after the email is sent successfully so that a failed email leaves
# no orphan records in the database.
try:
db.flush()
except Exception:
db.rollback()
raise
base_url = str(request.base_url).rstrip("/")
try:
send_verification_email(body.email, body.username, token, base_url)
except Exception as exc:
# Email failed — roll back so no unverifiable user row persists.
# The user can simply try registering again once SMTP is fixed.
db.rollback()
logger.warning("Signup email failed for %s: %s", body.email, exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
) from exc
db.commit()
logger.info("New local user registered: %s", body.email)
return {"message": "Verification email sent. Please check your inbox."}
@router.get("/verify-email", include_in_schema=False)
async def verify_email(request: Request, db: DbSession) -> Any:
"""Activate a local user account from the email verification link.
Redirects to the login page on failure, or to onboarding/upload on success.
"""
token = request.query_params.get("token", "")
user = db.query(LocalUser).filter(LocalUser.email_verification_token == token).first()
if not user:
return RedirectResponse(
url="/login?error=Invalid+or+expired+verification+link",
status_code=302,
)
if is_token_expired(user.email_verification_sent_at):
return RedirectResponse(
url="/login?error=Verification+link+has+expired.+Please+request+a+new+one",
status_code=302,
)
user.is_active = True
user.email_verification_token = None
user.email_verification_sent_at = None
# Ensure profile exists
if not db.query(UserProfile).filter(UserProfile.user_id == user.email).first():
db.add(UserProfile(user_id=user.email, display_name=user.display_name or user.username))
db.commit()
request.session["user"] = build_session_user(user)
logger.info("[SECURITY] EMAIL_VERIFIED user=%s", user.email)
profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
request.session["post_onboarding_redirect"] = post_onboarding
return RedirectResponse(url="/onboarding", status_code=302)
return RedirectResponse(url="/upload", status_code=302)
@router.post("/api/auth/resend-verification")
async def resend_verification(request: Request, body: ResendVerificationBody, db: DbSession) -> dict[str, str]:
"""Re-send the verification email for a pending account.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if not user or user.is_active:
return {"message": "Verification email resent if account exists."}
token = generate_token()
user.email_verification_token = token
user.email_verification_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_verification_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Failed to resend verification email to %s: %s", user.email, exc)
return {"message": "Verification email resent if account exists."}
@router.post("/api/auth/request-password-reset")
async def request_password_reset(request: Request, body: PasswordResetRequestBody, db: DbSession) -> dict[str, str]:
"""Send a password reset email.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if not user:
return {"message": "Password reset email sent if account exists."}
token = generate_token()
user.password_reset_token = token
user.password_reset_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_password_reset_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Failed to send password reset email to %s: %s", user.email, exc)
return {"message": "Password reset email sent if account exists."}
@router.post("/api/auth/reset-password")
async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, str]:
"""Set a new password using a valid reset token.
Raises:
400: Token is invalid or expired.
422: Passwords do not match.
"""
user = db.query(LocalUser).filter(LocalUser.password_reset_token == body.token).first()
if not user or is_token_expired(user.password_reset_sent_at):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token.",
)
if body.new_password != body.new_password_confirm:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Passwords do not match.",
)
user.hashed_password = hash_password(body.new_password)
user.password_reset_token = None
user.password_reset_sent_at = None
db.commit()
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
return {"message": "Password updated successfully."}