0f6a1ee1ec
The QR login page countdown timer compared the server's UTC expiration timestamp against the client's local clock, causing the QR code to appear immediately expired when the client clock was ahead of the server. Changes: - Add ttl_seconds field to CreateChallengeResponse (seconds until expiry) - Frontend countdown now uses relative elapsed time since response was received, eliminating clock-skew issues - Mobile app: replace alert-only QR button with actual camera-based QR code scanner using expo-camera - Add QRScannerScreen with barcode scanning, permission handling, and scan area overlay - Update camera permission description to mention QR code scanning - Add tests for ttl_seconds computation 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
|