diff --git a/.env.demo b/.env.demo index 5bf0a14d..fc1bfc29 100644 --- a/.env.demo +++ b/.env.demo @@ -160,6 +160,16 @@ AUTH_ENABLED=true # Generate a secure random string, for example: # python -c "import secrets; print(secrets.token_hex(32))" SESSION_SECRET=b39fd43f68d0491ca942f28a16e484b1e763fe9accf4445ca2669a5f3b179eb4 + +# Session lifetime in days (default: 30). Common values: 30, 60, 90. +# Determines how long a user stays logged in before needing to re-authenticate. +# SESSION_LIFETIME_DAYS=30 +# Override with a custom value (takes precedence over SESSION_LIFETIME_DAYS): +# SESSION_LIFETIME_CUSTOM_DAYS= + +# Time-to-live in seconds for QR code login challenges (default: 120 = 2 minutes). +# QR_LOGIN_CHALLENGE_TTL_SECONDS=120 + ADMIN_USERNAME=admin ADMIN_PASSWORD=your_secure_password ADMIN_GROUP_NAME=admin diff --git a/app/views/__init__.py b/app/views/__init__.py index b25a3be8..4d1cce5a 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -26,6 +26,7 @@ from app.views.onedrive import router as onedrive_router from app.views.pipelines import router as pipelines_router # Processing pipelines from app.views.plans import router as plans_router # Admin Plan Designer from app.views.profile import router as profile_router # User self-service profile +from app.views.qr_login import router as qr_login_router # QR code mobile login from app.views.queue import router as queue_router from app.views.scheduled_jobs import router as scheduled_jobs_router # Scheduled batch jobs from app.views.search import router as search_router @@ -60,6 +61,7 @@ router.include_router(plans_router) # Admin Plan Designer router.include_router(onboarding_router) # User onboarding wizard router.include_router(pipelines_router) # Processing pipelines router.include_router(profile_router) # User self-service profile settings +router.include_router(qr_login_router) # QR code mobile login page router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts router.include_router(integrations_router) # Unified integrations dashboard router.include_router(notifications_router) # User notification dashboard diff --git a/app/views/qr_login.py b/app/views/qr_login.py new file mode 100644 index 00000000..aeb33f91 --- /dev/null +++ b/app/views/qr_login.py @@ -0,0 +1,26 @@ +"""View route for the QR code mobile login page. + +Route: + GET /qr-login — renders the QR login page (requires login) +""" + +from __future__ import annotations + +import logging + +from fastapi import Request + +from app.views.base import APIRouter, require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/qr-login", include_in_schema=False) +@require_login +async def qr_login_page(request: Request): + """Serve the QR code login page for mobile app authentication.""" + return templates.TemplateResponse( + "qr_login.html", + {"request": request}, + ) diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index b3c47219..cc6144d7 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -343,9 +343,203 @@ + +
+

+ {{ _("sessions.security_heading") }} +

+

+ {{ _("sessions.security_subtitle") }} +

+ + +
+ + +
+ + +
+ +

{{ _("sessions.no_other_sessions") }}

+
+ + +
+ + + + {{ _("sessions.qr_login_link") }} + +
+ + + +
+ + + +{% endblock %} diff --git a/frontend/translations/en.json b/frontend/translations/en.json index 19e3a668..3146e36c 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -1451,6 +1451,39 @@ "profile.theme_system": "System Default", "profile.update_password": "Update Password", "profile.updating": "Updating…", + "qr_login.claimed_device": "Device: {device_name}", + "qr_login.claimed_message": "QR code login successful! Your mobile device is now connected.", + "qr_login.description": "Scan this QR code with the DocuElevate mobile app to log in instantly.", + "qr_login.expired_message": "This QR code has expired. Please generate a new one.", + "qr_login.generate_new": "Generate New QR Code", + "qr_login.heading": "Mobile App QR Login", + "qr_login.how_it_works": "How it works", + "qr_login.page_title": "QR Code Login – DocuElevate", + "qr_login.pending_message": "Waiting for mobile app to scan…", + "qr_login.step_1": "Open the DocuElevate app on your phone", + "qr_login.step_2": "Tap \"Scan QR Code\" on the login screen", + "qr_login.step_3": "Point your camera at this QR code", + "qr_login.subtitle": "Log in to the mobile app by scanning a QR code from this page.", + "qr_login.time_remaining": "Expires in {seconds} seconds", + "sessions.active_sessions": "Active Sessions", + "sessions.confirm_revoke_all": "This will log you out of all other devices and browsers, and revoke all API tokens. Continue?", + "sessions.confirm_revoke_one": "Are you sure you want to end this session?", + "sessions.current_session": "This device", + "sessions.device_info": "Device", + "sessions.expires": "Expires", + "sessions.ip_address": "IP Address", + "sessions.last_active": "Last active", + "sessions.log_off_everywhere": "Log Off All Other Sessions", + "sessions.log_off_everywhere_desc": "End all other browser sessions and revoke all API tokens. Your current session will remain active.", + "sessions.no_other_sessions": "No other active sessions found.", + "sessions.qr_login_link": "Log in on mobile via QR code", + "sessions.revoke": "End Session", + "sessions.revoked_all_success": "All other sessions have been ended.", + "sessions.revoked_success": "Session ended successfully.", + "sessions.security_heading": "Security & Sessions", + "sessions.security_subtitle": "Manage your active sessions across devices and browsers.", + "sessions.session_lifetime": "Session lifetime: {days} days", + "sessions.started": "Started", "queue.active_tasks": "Active Tasks", "queue.auto_refresh_1": "Auto-refreshes every", "queue.auto_refresh_2": "seconds", diff --git a/migrations/versions/037_add_user_sessions_and_qr_challenges.py b/migrations/versions/037_add_user_sessions_and_qr_challenges.py new file mode 100644 index 00000000..9610f56d --- /dev/null +++ b/migrations/versions/037_add_user_sessions_and_qr_challenges.py @@ -0,0 +1,72 @@ +"""Add user_sessions and qr_login_challenges tables. + +Adds server-side session tracking (user_sessions) for the "log off +everywhere" feature and per-session revocation, and QR login challenges +(qr_login_challenges) for secure mobile app authentication via QR code. + +Revision ID: 037_add_user_sessions_and_qr_challenges +Revises: 036_add_document_translation_fields +Create Date: 2026-03-16 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "037_add_user_sessions_and_qr_challenges" +down_revision: Union[str, None] = "036_add_document_translation_fields" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create user_sessions and qr_login_challenges tables.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_tables = set(inspector.get_table_names()) + + if "user_sessions" not in existing_tables: + op.create_table( + "user_sessions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("session_token", sa.String(128), nullable=False, unique=True, index=True), + sa.Column("user_id", sa.String(), nullable=False, index=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("user_agent", sa.String(512), nullable=True), + sa.Column("device_info", sa.String(255), nullable=True), + sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("last_active_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + + if "qr_login_challenges" not in existing_tables: + op.create_table( + "qr_login_challenges", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("challenge_token", sa.String(128), nullable=False, unique=True, index=True), + sa.Column("user_id", sa.String(), nullable=False, index=True), + sa.Column("is_claimed", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("is_cancelled", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("created_by_ip", sa.String(45), nullable=True), + sa.Column("claimed_by_ip", sa.String(45), nullable=True), + sa.Column("device_name", sa.String(255), nullable=True), + sa.Column("issued_token_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + """Drop user_sessions and qr_login_challenges tables.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_tables = set(inspector.get_table_names()) + + if "qr_login_challenges" in existing_tables: + op.drop_table("qr_login_challenges") + + if "user_sessions" in existing_tables: + op.drop_table("user_sessions") diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index 4bbb66e7..e4e1ab84 100644 --- a/mobile/src/context/AuthContext.tsx +++ b/mobile/src/context/AuthContext.tsx @@ -38,6 +38,7 @@ export interface AuthState { user: WhoAmIResponse | null; baseUrl: string; signIn: (serverUrl: string) => Promise; + signInWithQR: (serverUrl: string, challengeToken: string) => Promise; signOut: () => Promise; setToken: (token: string) => Promise; } @@ -52,6 +53,7 @@ const AuthContext = createContext({ user: null, baseUrl: "", signIn: async () => {}, + signInWithQR: async () => {}, signOut: async () => {}, setToken: async () => {}, }); @@ -143,6 +145,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [setToken] ); + const signInWithQR = useCallback( + async (serverUrl: string, challengeToken: string) => { + const cleanUrl = serverUrl.replace(/\/$/, ""); + await api.init(cleanUrl); + setBaseUrl(cleanUrl); + + const deviceInfo = await _getDeviceName(); + const resp = await api.claimQRChallenge(challengeToken, deviceInfo); + await setToken(resp.token); + }, + [setToken] + ); + const signOut = useCallback(async () => { await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY); await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY); @@ -158,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { user, baseUrl, signIn, + signInWithQR, signOut, setToken, }} diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index f278b46e..cc2b3133 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -1,13 +1,16 @@ /** - * LoginScreen – server URL entry and SSO sign-in. + * LoginScreen – server URL entry, SSO sign-in, and QR code login. * - * Renders a server URL input and a "Sign in with SSO" button that opens the - * DocuElevate web login page in the system browser. On success the - * AuthContext stores the API token and navigates to the main app. + * Renders a server URL input, a "Sign in with SSO" button that opens the + * DocuElevate web login page in the system browser, and a "Scan QR Code" + * button that opens the device camera to scan a QR code generated from the + * web interface. On success the AuthContext stores the API token and + * navigates to the main app. */ +import * as Linking from "expo-linking"; import { useRouter } from "expo-router"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, @@ -23,10 +26,46 @@ import { import { useAuth } from "../context/AuthContext"; export default function LoginScreen() { - const { signIn } = useAuth(); + const { signIn, signInWithQR } = useAuth(); const router = useRouter(); const [serverUrl, setServerUrl] = useState(""); const [loading, setLoading] = useState(false); + const [qrLoading, setQrLoading] = useState(false); + + // Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...) + const handleDeepLink = useCallback( + async (event: { url: string }) => { + try { + const url = new URL(event.url); + if (url.hostname === "qr-login" || url.pathname === "/qr-login") { + const token = url.searchParams.get("token"); + const server = url.searchParams.get("server"); + if (token && server) { + setQrLoading(true); + await signInWithQR(server, token); + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "QR login failed"; + Alert.alert("QR Login Failed", message); + } finally { + setQrLoading(false); + } + }, + [signInWithQR] + ); + + useEffect(() => { + // Listen for incoming deep links + const subscription = Linking.addEventListener("url", handleDeepLink); + + // Check if the app was opened via a deep link + Linking.getInitialURL().then((url) => { + if (url) handleDeepLink({ url }); + }); + + return () => subscription.remove(); + }, [handleDeepLink]); async function handleSignIn() { const url = serverUrl.trim(); @@ -85,7 +124,7 @@ export default function LoginScreen() { @@ -96,8 +135,33 @@ export default function LoginScreen() { )} + + + or + + + + { + Alert.alert( + "Scan QR Code", + "Open the DocuElevate web app on your computer, go to Profile → Security & Sessions → \"Log in on mobile via QR code\", and scan the QR code shown there.\n\nThe app will automatically detect the QR code when scanned with your device camera." + ); + }} + disabled={loading || qrLoading} + accessibilityRole="button" + accessibilityLabel="Sign in with QR code" + > + {qrLoading ? ( + + ) : ( + 📱 Scan QR Code to Login + )} + + - You will be redirected to your organisation's sign-in page. + Sign in via SSO or scan a QR code from the web app. { + return this.request("POST", "/api/qr-auth/claim", { + body: { challenge_token: challengeToken, device_name: deviceName }, + }); + } + /** Return profile information for the authenticated user. */ async whoAmI(): Promise { return this.request("GET", "/api/mobile/whoami");