a08b103271
Merges origin/main (v0.156.0) into the classification feature branch, properly resolving all 23 merge conflicts: - Auto-generated files (BUILD_DATE, VERSION, etc.): accept main's version - Non-classification files (SharePoint, QR auth, session mgmt, mobile): accept main's version - Classification files (api/__init__.py, models.py, migrations/env.py, conftest.py): keep classification additions alongside main's content Previously the branch was incorrectly removing files from main (SharePoint integration, QR scanner, session management). This merge properly preserves all main branch content while maintaining the classification feature additions. Migration chain validated: 038_add_classification_rules chains from 037_add_user_sessions_and_qr_challenges. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
"""QR code login API endpoints for mobile app authentication.
|
|
|
|
Provides a secure challenge-response flow for logging into the mobile app
|
|
by scanning a QR code displayed in the web interface:
|
|
|
|
1. **Web user** calls ``POST /qr-auth/challenge`` → receives a time-limited
|
|
challenge token (encoded in the QR code).
|
|
2. **Web UI** polls ``GET /qr-auth/challenge/{id}/status`` to detect when
|
|
the mobile app has claimed the challenge.
|
|
3. **Mobile app** scans the QR code and calls ``POST /qr-auth/claim`` with
|
|
the challenge token + device name → receives an API token.
|
|
|
|
Security properties:
|
|
* Challenges expire after a configurable TTL (default 2 minutes).
|
|
* Single-use: once claimed, a challenge cannot be reused (replay-safe).
|
|
* Cryptographically random 64-byte tokens.
|
|
* IP addresses are logged for audit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import require_login
|
|
from app.database import get_db
|
|
from app.middleware.audit_log import get_client_ip
|
|
from app.utils.session_manager import (
|
|
claim_qr_challenge,
|
|
create_qr_challenge,
|
|
get_challenge_status,
|
|
)
|
|
from app.utils.user_scope import get_current_owner_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/qr-auth", tags=["qr-auth"])
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_owner_id(request: Request) -> str:
|
|
"""Return the current user's owner ID, raising 401 if unauthenticated."""
|
|
owner_id = get_current_owner_id(request)
|
|
if not owner_id:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
return owner_id
|
|
|
|
|
|
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request / Response schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CreateChallengeResponse(BaseModel):
|
|
"""Response after creating a QR login challenge."""
|
|
|
|
challenge_id: int
|
|
challenge_token: str
|
|
expires_at: datetime
|
|
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
|
|
qr_payload: str = Field(description="The string to encode in the QR code.")
|
|
|
|
|
|
class ChallengeStatusResponse(BaseModel):
|
|
"""Response for polling the status of a QR challenge."""
|
|
|
|
id: int
|
|
status: str # "pending", "claimed", "expired", "cancelled"
|
|
device_name: str | None = None
|
|
claimed_at: datetime | None = None
|
|
expires_at: datetime
|
|
|
|
|
|
class ClaimChallengeRequest(BaseModel):
|
|
"""Request body for claiming a QR login challenge."""
|
|
|
|
challenge_token: str = Field(min_length=1, max_length=256)
|
|
device_name: str = Field(
|
|
default="Mobile App",
|
|
min_length=1,
|
|
max_length=120,
|
|
description="Human-readable device name.",
|
|
)
|
|
|
|
|
|
class ClaimChallengeResponse(BaseModel):
|
|
"""Response after successfully claiming a QR challenge."""
|
|
|
|
token: str
|
|
token_id: int
|
|
name: str
|
|
owner_id: str
|
|
created_at: datetime
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/challenge", status_code=status.HTTP_201_CREATED, response_model=CreateChallengeResponse)
|
|
@require_login
|
|
async def create_challenge(
|
|
request: Request,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, Any]:
|
|
"""Create a new QR login challenge.
|
|
|
|
The returned ``qr_payload`` should be encoded into a QR code and
|
|
displayed to the user. The mobile app scans this QR code and
|
|
calls the ``/claim`` endpoint.
|
|
"""
|
|
ip = get_client_ip(request)
|
|
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
|
|
|
|
# The QR payload is a JSON-like string with enough info for the mobile
|
|
# app to know the server URL and challenge token.
|
|
base_url = str(request.base_url).rstrip("/")
|
|
qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}"
|
|
|
|
# Compute the TTL in seconds so the client can run a countdown timer
|
|
# without comparing absolute timestamps (which breaks when client and
|
|
# server clocks are out of sync).
|
|
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
|
|
|
|
return {
|
|
"challenge_id": challenge.id,
|
|
"challenge_token": challenge.challenge_token,
|
|
"expires_at": challenge.expires_at,
|
|
"ttl_seconds": ttl_seconds,
|
|
"qr_payload": qr_payload,
|
|
}
|
|
|
|
|
|
@router.get("/challenge/{challenge_id}/status", response_model=ChallengeStatusResponse)
|
|
@require_login
|
|
async def poll_challenge_status(
|
|
request: Request,
|
|
challenge_id: int,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, Any]:
|
|
"""Poll the status of a QR login challenge.
|
|
|
|
The web UI calls this endpoint every few seconds to check if the
|
|
mobile app has scanned the QR code and claimed the challenge.
|
|
"""
|
|
result = get_challenge_status(db, challenge_id, owner_id)
|
|
if not result:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
|
|
return result
|
|
|
|
|
|
@router.post("/claim", response_model=ClaimChallengeResponse)
|
|
async def claim_challenge(
|
|
request: Request,
|
|
body: ClaimChallengeRequest,
|
|
db: DbSession,
|
|
) -> dict[str, Any]:
|
|
"""Claim a QR login challenge and receive an API token.
|
|
|
|
This endpoint is called by the mobile app after scanning a QR code.
|
|
It does **not** require authentication — the challenge token itself
|
|
serves as proof that the user authorized this login from their web
|
|
session.
|
|
"""
|
|
ip = get_client_ip(request)
|
|
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
|
|
|
if not result:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid, expired, or already claimed challenge.",
|
|
)
|
|
|
|
try:
|
|
from app.utils.audit_service import record_event
|
|
|
|
record_event(
|
|
db,
|
|
action="qr_login_claimed",
|
|
user=result["owner_id"],
|
|
resource_type="session",
|
|
ip_address=ip,
|
|
details={"device_name": body.device_name, "token_id": result["token_id"]},
|
|
severity="info",
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to write QR login audit event", exc_info=True)
|
|
|
|
return result
|