diff --git a/backend/alembic/versions/f2a3b4c5d6e7_totp_2fa.py b/backend/alembic/versions/f2a3b4c5d6e7_totp_2fa.py new file mode 100644 index 0000000..e99946f --- /dev/null +++ b/backend/alembic/versions/f2a3b4c5d6e7_totp_2fa.py @@ -0,0 +1,33 @@ +"""add users TOTP two-factor columns (secret, enabled flag, recovery codes) + +Revision ID: f2a3b4c5d6e7 +Revises: e1f2a3b4c5d6 +Create Date: 2026-07-11 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'f2a3b4c5d6e7' +down_revision: Union[str, None] = 'e1f2a3b4c5d6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('totp_secret', sa.String(), nullable=True)) + batch_op.add_column( + sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default=sa.false()) + ) + batch_op.add_column(sa.Column('totp_recovery_codes', sa.String(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('totp_recovery_codes') + batch_op.drop_column('totp_enabled') + batch_op.drop_column('totp_secret') diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9965182..4b36302 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "starlette-session==0.4.3", "openpyxl==3.1.5", "msoffcrypto-tool==6.0.0", + "pyotp==2.9.0", + "qrcode==8.2", ] [project.optional-dependencies] diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py index 9bb8187..3c77b65 100644 --- a/backend/ten31portal/cli.py +++ b/backend/ten31portal/cli.py @@ -87,6 +87,28 @@ def reset_password(args: argparse.Namespace) -> None: print(f"Password reset for {user.name} ({user.username}).") +def reset_2fa(args: argparse.Namespace) -> None: + """Clear a user's two-factor enrollment so they can sign in with password alone.""" + run_migrations() + + with Session(engine) as session: + user = _find_user(session, args.username, args.email) + if user is None: + who = args.username or args.email + print(f"Error: no user found for '{who}'.", file=sys.stderr) + sys.exit(1) + if not user.totp_enabled and not user.totp_secret: + print(f"{user.name} ({user.username}) does not have two-factor enabled.") + return + user.totp_secret = None + user.totp_enabled = False + user.totp_recovery_codes = None + session.add(user) + session.commit() + print(f"Two-factor disabled for {user.name} ({user.username}). " + "They can sign in with just their password and re-enroll from the app.") + + def enable_investor_logins(args: argparse.Namespace) -> None: """Give every no-login investor account the shared default password and enable sign-in. @@ -325,6 +347,10 @@ def main() -> None: reset.add_argument("--email", required=False, default=None) reset.add_argument("--password", required=True) + r2fa = sub.add_parser("reset-2fa", help="Clear a user's two-factor enrollment (lost phone)") + r2fa.add_argument("--username", required=False, default=None) + r2fa.add_argument("--email", required=False, default=None) + sub.add_parser("list-users", help="List all user accounts") sub.add_parser( @@ -357,6 +383,8 @@ def main() -> None: create_user(args) elif args.command == "reset-password": reset_password(args) + elif args.command == "reset-2fa": + reset_2fa(args) elif args.command == "list-users": list_users(args) elif args.command == "enable-investor-logins": diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 61767d4..7e3b2a1 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -79,6 +79,12 @@ class User(SQLModel, table=True): # When this investor last loaded their documents list — docs newer than this get a "New" # badge in the portal. Null until their first visit (nothing badged for brand-new logins). docs_seen_at: datetime | None = Field(default=None) + # Two-factor auth (optional, per-user opt-in). The secret is set at setup time but only + # counts once totp_enabled is True (enrollment is confirmed with a first valid code). + totp_secret: str | None = Field(default=None) + totp_enabled: bool = Field(default=False) + # JSON list of sha256 hex digests of unused one-time recovery codes. + totp_recovery_codes: str | None = Field(default=None) created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py index 2854de1..ffe278c 100644 --- a/backend/ten31portal/routers/auth_router.py +++ b/backend/ten31portal/routers/auth_router.py @@ -2,22 +2,38 @@ import os import secrets +import time from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select -from ten31portal import config +from ten31portal import config, totp +from ten31portal.audit import record_audit from ten31portal.auth import get_current_user, hash_password, verify_password from ten31portal.database import get_session from ten31portal.models import User from ten31portal.ratelimit import SlidingWindowLimiter -from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse +from ten31portal.schemas import ( + ChangePasswordRequest, + LoginPending2FA, + LoginRequest, + TotpConfirmRequest, + TotpConfirmResponse, + TotpDisableRequest, + TotpSetupResponse, + TotpVerifyRequest, + UserResponse, +) router = APIRouter(prefix="/api/auth", tags=["auth"]) # Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes. +# Second-factor code guesses count against the same window. _login_limiter = SlidingWindowLimiter(max_attempts=10, window_seconds=300) +# How long a password-accepted session may wait for its second factor. +_PENDING_2FA_MAX_AGE = 300 # seconds + # A throwaway hash verified when no user matches, so a missing account costs the same argon2 # time as a real one — otherwise the timing difference reveals which usernames exist. _DUMMY_HASH = hash_password(secrets.token_urlsafe(16)) @@ -28,7 +44,7 @@ def login( body: LoginRequest, request: Request, session: Session = Depends(get_session), -) -> UserResponse: +) -> UserResponse | LoginPending2FA: client_ip = request.client.host if request.client else "unknown" retry_after = _login_limiter.retry_after(client_ip) if retry_after > 0: @@ -62,11 +78,61 @@ def login( raise HTTPException(status_code=401, detail="This account does not have a login yet.") if not user.is_active: raise HTTPException(status_code=401, detail="Account disabled") + if user.totp_enabled: + # Password accepted, but don't sign the session in yet — park the login until + # /login/verify-totp confirms the second factor. + request.session.clear() + request.session["pending_2fa_user_id"] = user.id + request.session["pending_2fa_at"] = time.time() + return LoginPending2FA() _login_limiter.reset(client_ip) request.session["user_id"] = user.id return UserResponse.model_validate(user, from_attributes=True) +@router.post("/login/verify-totp") +def verify_totp_login( + body: TotpVerifyRequest, + request: Request, + session: Session = Depends(get_session), +) -> UserResponse: + """Second login step: accept an authenticator code or an unused recovery code.""" + client_ip = request.client.host if request.client else "unknown" + retry_after = _login_limiter.retry_after(client_ip) + if retry_after > 0: + raise HTTPException( + status_code=429, + detail="Too many attempts. Please wait a moment and try again.", + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + + user_id = request.session.get("pending_2fa_user_id") + started = request.session.get("pending_2fa_at", 0) + if user_id is None or time.time() - started > _PENDING_2FA_MAX_AGE: + request.session.clear() + raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.") + user = session.get(User, user_id) + if user is None or not user.is_active or not user.totp_enabled or not user.totp_secret: + request.session.clear() + raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.") + + if not totp.verify_code(user.totp_secret, body.code): + # Not a current authenticator code — maybe a one-time recovery code. + remaining = totp.consume_recovery_code(user.totp_recovery_codes, body.code) + if remaining is None: + _login_limiter.record_failure(client_ip) + raise HTTPException(status_code=401, detail="Invalid code. Try again.") + user.totp_recovery_codes = remaining + session.add(user) + record_audit(session, user.id, "totp_recovery_used", "user", user.id) + session.commit() + + _login_limiter.reset(client_ip) + request.session.clear() + request.session["user_id"] = user.id + return UserResponse.model_validate(user, from_attributes=True) + + @router.post("/logout") def logout(request: Request) -> dict[str, str]: request.session.clear() @@ -99,3 +165,63 @@ def change_password( except OSError: pass return {"status": "ok"} + + +# --- Two-factor enrollment (per-user opt-in) --- + +@router.post("/totp/setup") +def totp_setup( + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> TotpSetupResponse: + """Start enrollment: mint a secret and return the QR. Not active until confirmed.""" + if user.totp_enabled: + raise HTTPException( + status_code=400, detail="Two-factor is already enabled. Disable it first to re-enroll." + ) + secret = totp.new_secret() + user.totp_secret = secret + session.add(user) + session.commit() + uri = totp.otpauth_uri(secret, user.username) + return TotpSetupResponse(secret=secret, otpauth_uri=uri, qr_svg=totp.qr_svg(uri)) + + +@router.post("/totp/confirm") +def totp_confirm( + body: TotpConfirmRequest, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> TotpConfirmResponse: + """Finish enrollment: prove the authenticator works, then hand out recovery codes.""" + if user.totp_enabled: + raise HTTPException(status_code=400, detail="Two-factor is already enabled.") + if not user.totp_secret: + raise HTTPException(status_code=400, detail="Start two-factor setup first.") + if not totp.verify_code(user.totp_secret, body.code): + raise HTTPException(status_code=400, detail="That code didn't match. Try again.") + codes, digests_json = totp.generate_recovery_codes() + user.totp_enabled = True + user.totp_recovery_codes = digests_json + session.add(user) + record_audit(session, user.id, "totp_enabled", "user", user.id) + session.commit() + return TotpConfirmResponse(recovery_codes=codes) + + +@router.post("/totp/disable") +def totp_disable( + body: TotpDisableRequest, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> dict[str, str]: + """Turn off two-factor (requires the account password, not just a live session).""" + if not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=400, detail="Password is incorrect.") + user.totp_secret = None + user.totp_enabled = False + user.totp_recovery_codes = None + session.add(user) + record_audit(session, user.id, "totp_disabled", "user", user.id) + session.commit() + return {"status": "ok"} diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index aaa2c62..6fe0ab7 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -26,9 +26,37 @@ class UserResponse(BaseModel): is_active: bool is_service_admin: bool = False primary_account_id: int | None = None # set when this account logs in under another + totp_enabled: bool = False created_at: datetime +class LoginPending2FA(BaseModel): + """Password accepted, waiting on the second factor before the session is signed in.""" + requires_2fa: bool = True + + +class TotpVerifyRequest(BaseModel): + code: str # 6-digit authenticator code, or a one-time recovery code + + +class TotpSetupResponse(BaseModel): + secret: str + otpauth_uri: str + qr_svg: str + + +class TotpConfirmRequest(BaseModel): + code: str + + +class TotpConfirmResponse(BaseModel): + recovery_codes: list[str] # shown exactly once + + +class TotpDisableRequest(BaseModel): + password: str + + # --- User administration --- class UserCreate(BaseModel): diff --git a/backend/ten31portal/totp.py b/backend/ten31portal/totp.py new file mode 100644 index 0000000..9d24bd6 --- /dev/null +++ b/backend/ten31portal/totp.py @@ -0,0 +1,71 @@ +"""TOTP two-factor helpers: secrets, QR enrollment, code checks, recovery codes. + +Recovery codes are random (80 bits each), so a fast sha256 digest is enough at rest — +unlike passwords they can't be dictionary-attacked. Each code is one-time: a successful +match removes its hash from the stored list. +""" + +import hashlib +import io +import json +import secrets + +import pyotp +import qrcode +import qrcode.image.svg + +ISSUER = "Ten31 Portal" +RECOVERY_CODE_COUNT = 8 + + +def new_secret() -> str: + return pyotp.random_base32() + + +def otpauth_uri(secret: str, account_name: str) -> str: + return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=ISSUER) + + +def qr_svg(uri: str) -> str: + """The enrollment QR as a standalone SVG document (no raster deps needed).""" + img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=14) + buf = io.BytesIO() + img.save(buf) + return buf.getvalue().decode() + + +def verify_code(secret: str, code: str) -> bool: + # valid_window=1 accepts the neighbouring 30s steps, tolerating clock drift. + return pyotp.TOTP(secret).verify(code.strip().replace(" ", ""), valid_window=1) + + +def _normalize_recovery(code: str) -> str: + return code.strip().replace("-", "").replace(" ", "").lower() + + +def _digest(code: str) -> str: + return hashlib.sha256(_normalize_recovery(code).encode()).hexdigest() + + +def generate_recovery_codes() -> tuple[list[str], str]: + """Return (plaintext codes to show once, JSON of their digests to store).""" + codes = [] + for _ in range(RECOVERY_CODE_COUNT): + raw = secrets.token_hex(10) # 20 hex chars, 80 bits + codes.append(f"{raw[:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:]}") + return codes, json.dumps([_digest(c) for c in codes]) + + +def consume_recovery_code(stored_json: str | None, code: str) -> str | None: + """If ``code`` matches an unused recovery code, return the updated JSON without it. + + Returns None when the code doesn't match (or none are stored). + """ + if not stored_json: + return None + digests: list[str] = json.loads(stored_json) + d = _digest(code) + if d not in digests: + return None + digests.remove(d) + return json.dumps(digests) diff --git a/backend/tests/test_totp.py b/backend/tests/test_totp.py new file mode 100644 index 0000000..7aee458 --- /dev/null +++ b/backend/tests/test_totp.py @@ -0,0 +1,123 @@ +"""TOTP two-factor: enrollment, two-step login, recovery codes, disable, CLI-style reset.""" + +import pyotp + +from tests.conftest import make_user +from ten31portal.models import User, UserRole + + +def _enroll(client): + """Run the full setup+confirm flow for the signed-in user; return (secret, recovery_codes).""" + setup = client.post("/api/auth/totp/setup") + assert setup.status_code == 200, setup.text + secret = setup.json()["secret"] + assert setup.json()["qr_svg"].lstrip().startswith(" ({ username: '' }), + async ({ input, effects }) => { + try { + const result = await runCli( + effects, + ['reset-2fa', '--username', input.username], + 'reset-2fa-task', + ) + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to reset two-factor') + } + return { + version: '1' as const, + title: 'Two-Factor Reset', + message: `Two-factor cleared for ${input.username}. They can sign in with their password and re-enroll from the app.`, + result: null, + } + } catch (e: any) { + return errorResult(`Failed to reset two-factor: ${e.message || e}`) + } + }, +) + // ============================================ // Action: List Users // ============================================ @@ -500,6 +548,7 @@ const enableInvestorLoginsAction = Action.withoutInput( export const actions = sdk.Actions.of() .addAction(createUserAction) .addAction(resetPasswordAction) + .addAction(resetTwoFactorAction) .addAction(showAdminPasswordAction) .addAction(listUsersAction) .addAction(enableInvestorLoginsAction) diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 4d0ed9c..5cd5a51 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_37 as current } from './v_0_2_37' +export { v_0_2_38 as current } from './v_0_2_38' import { v_0_1_0 } from './v_0_1_0' import { v_0_2_0 } from './v_0_2_0' import { v_0_2_1 } from './v_0_2_1' @@ -36,4 +36,5 @@ import { v_0_2_33 } from './v_0_2_33' import { v_0_2_34 } from './v_0_2_34' import { v_0_2_35 } from './v_0_2_35' import { v_0_2_36 } from './v_0_2_36' -export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36] +import { v_0_2_37 } from './v_0_2_37' +export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37] diff --git a/deploy/startos/install/versions/v_0_2_38.ts b/deploy/startos/install/versions/v_0_2_38.ts new file mode 100644 index 0000000..6b89856 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_38.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_38 = VersionInfo.of({ + version: '0.2.38:0', + releaseNotes: { + en_US: + 'Two-factor authentication (optional, per user): enroll an authenticator app from the "Two-factor" option next to Change password; sign-in then asks for a 6-digit code. One-time recovery codes are issued at enrollment, and a new "Reset Two-Factor" action clears a lost enrollment so the user can sign in with just their password.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 815cba6..13e14bd 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // Bump CACHE on each release so old entries are purged. -const CACHE = 'ten31-portal-0.2.37' +const CACHE = 'ten31-portal-0.2.38' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9baa08e..5a35678 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -44,9 +44,15 @@ export interface User { is_active: boolean; is_service_admin: boolean; primary_account_id: number | null; + totp_enabled: boolean; created_at: string; } +/** Password was accepted but the account needs its authenticator code to finish signing in. */ +export interface LoginPending2FA { + requires_2fa: true; +} + export interface LinkedAccount { id: number; name: string; @@ -279,11 +285,17 @@ async function request(path: string, options?: RequestInit): Promise { export const api = { login: (login: string, password: string) => - request("/api/auth/login", { + request("/api/auth/login", { method: "POST", body: JSON.stringify({ login, password }), }), + verifyTotp: (code: string) => + request("/api/auth/login/verify-totp", { + method: "POST", + body: JSON.stringify({ code }), + }), + logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }), me: () => request("/api/auth/me"), @@ -294,6 +306,23 @@ export const api = { body: JSON.stringify({ current_password, new_password }), }), + totpSetup: () => + request<{ secret: string; otpauth_uri: string; qr_svg: string }>("/api/auth/totp/setup", { + method: "POST", + }), + + totpConfirm: (code: string) => + request<{ recovery_codes: string[] }>("/api/auth/totp/confirm", { + method: "POST", + body: JSON.stringify({ code }), + }), + + totpDisable: (password: string) => + request<{ status: string }>("/api/auth/totp/disable", { + method: "POST", + body: JSON.stringify({ password }), + }), + // Entities listEntities: () => request("/api/entities"), listEntityRollup: () => diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2340ca3..9c2ca2a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { isAdmin } from "../api"; import { roleLabel } from "../format"; import { APP_VERSION } from "../version"; import ChangePasswordModal from "./ChangePasswordModal"; +import TwoFactorModal from "./TwoFactorModal"; const TOP_NAV = [ { label: "Entities", path: "/" }, @@ -26,6 +27,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { const location = useLocation(); const [navOpen, setNavOpen] = useState(false); const [changingPw, setChangingPw] = useState(false); + const [managing2fa, setManaging2fa] = useState(false); const navItems = user && isAdmin(user.role) ? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV] : [...TOP_NAV, ...BOTTOM_NAV]; @@ -83,6 +85,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { > Change password +
v{APP_VERSION}
@@ -122,6 +130,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { {changingPw && setChangingPw(false)} />} + {managing2fa && setManaging2fa(false)} />} ); } diff --git a/frontend/src/components/TwoFactorModal.tsx b/frontend/src/components/TwoFactorModal.tsx new file mode 100644 index 0000000..9ef3773 --- /dev/null +++ b/frontend/src/components/TwoFactorModal.tsx @@ -0,0 +1,209 @@ +import { useState } from "react"; +import { api } from "../api"; +import { useAuth } from "../context/AuthContext"; +import PasswordInput from "./PasswordInput"; + +/** Self-service two-factor (authenticator app) enrollment for the signed-in user. */ +export default function TwoFactorModal({ onClose }: { onClose: () => void }) { + const { user, retry } = useAuth(); + const [enabled, setEnabled] = useState(!!user?.totp_enabled); + const [setup, setSetup] = useState<{ secret: string; qr_svg: string } | null>(null); + const [code, setCode] = useState(""); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [disabling, setDisabling] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + // Refresh the cached user (totp_enabled) once the modal closes after a change. + const close = () => { + if (enabled !== !!user?.totp_enabled) retry(); + onClose(); + }; + + const start = async () => { + setError(""); + setBusy(true); + try { + const s = await api.totpSetup(); + setSetup({ secret: s.secret, qr_svg: s.qr_svg }); + } catch (e: any) { + setError(e.message || "Could not start setup"); + } finally { + setBusy(false); + } + }; + + const confirm = async () => { + setError(""); + setBusy(true); + try { + const res = await api.totpConfirm(code); + setRecoveryCodes(res.recovery_codes); + setEnabled(true); + setSetup(null); + setCode(""); + } catch (e: any) { + setError(e.message || "Could not confirm code"); + } finally { + setBusy(false); + } + }; + + const disable = async () => { + setError(""); + setBusy(true); + try { + await api.totpDisable(password); + setEnabled(false); + setDisabling(false); + setPassword(""); + } catch (e: any) { + setError(e.message || "Could not disable two-factor"); + } finally { + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +

Two-factor authentication

+ + {recoveryCodes ? ( +
+

Two-factor authentication is now enabled.

+
+

+ Save these recovery codes somewhere safe. Each works once if you lose your + authenticator — they will not be shown again. +

+
+ {recoveryCodes.map((c) => ( + {c} + ))} +
+
+
+ +
+
+ ) : setup ? ( +
+

+ Scan this QR code with an authenticator app (Google Authenticator, 1Password, + Authy…), then enter the 6-digit code it shows. +

+
+ Two-factor enrollment QR code +
+

+ Can't scan? Enter this key manually: {setup.secret} +

+
+ + setCode(e.target.value)} + placeholder="6-digit code" + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" + /> +
+ {error &&

{error}

} +
+ + +
+
+ ) : enabled ? ( +
+

+ + Two-factor authentication is on. Signing in + requires a code from your authenticator app. +

+ {disabling ? ( +
+
+ + +
+ {error &&

{error}

} +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : ( +
+

+ Add a second step to sign-in: after your password, you'll enter a 6-digit code from + an authenticator app on your phone. This keeps your account safe even if your + password is guessed. +

+ {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index a9d3070..9539773 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -6,7 +6,10 @@ interface AuthState { loading: boolean; offline: boolean; // couldn't reach the server (vs. genuinely logged out) retry: () => void; - login: (handle: string, password: string) => Promise; + /** Resolves "ok" when signed in, "2fa" when an authenticator code is still needed. */ + login: (handle: string, password: string) => Promise<"ok" | "2fa">; + /** Second login step: authenticator code or a recovery code. */ + verifyTotp: (code: string) => Promise; logout: () => Promise; } @@ -34,8 +37,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { bootstrap(); }, [bootstrap]); - const login = async (handle: string, password: string) => { - const u = await api.login(handle, password); + const login = async (handle: string, password: string): Promise<"ok" | "2fa"> => { + const res = await api.login(handle, password); + if ("requires_2fa" in res) return "2fa"; + setUser(res); + setOffline(false); + return "ok"; + }; + + const verifyTotp = async (code: string) => { + const u = await api.verifyTotp(code); setUser(u); setOffline(false); }; @@ -46,7 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; return ( - + {children} ); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 72c36de..300181a 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,9 +3,11 @@ import { useAuth } from "../context/AuthContext"; import PasswordInput from "../components/PasswordInput"; export default function Login() { - const { login } = useAuth(); + const { login, verifyTotp } = useAuth(); const [handle, setHandle] = useState(""); const [password, setPassword] = useState(""); + const [needsCode, setNeedsCode] = useState(false); + const [code, setCode] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -14,7 +16,8 @@ export default function Login() { setError(""); setLoading(true); try { - await login(handle, password); + const result = await login(handle, password); + if (result === "2fa") setNeedsCode(true); } catch (err: any) { setError(err.message || "Login failed"); } finally { @@ -22,6 +25,25 @@ export default function Login() { } }; + const handleCodeSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await verifyTotp(code); + } catch (err: any) { + setError(err.message || "Verification failed"); + // A 401 "expired" means the pending window lapsed — send them back to step one. + if (/expired/i.test(err.message || "")) { + setNeedsCode(false); + setCode(""); + setPassword(""); + } + } finally { + setLoading(false); + } + }; + return (
@@ -29,31 +51,68 @@ export default function Login() {

Ten31 Portal

-
-
- - setHandle(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" - required - /> -
-
- - -
- {error &&

{error}

} - -
+ {needsCode ? ( +
+
+ + setCode(e.target.value)} + placeholder="6-digit code" + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" + required + /> +

+ Enter the code from your authenticator app, or one of your recovery codes. +

+
+ {error &&

{error}

} + + +
+ ) : ( +
+
+ + setHandle(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" + required + /> +
+
+ + +
+ {error &&

{error}

} + +
+ )}

Trouble signing in?{" "} diff --git a/frontend/src/portal/PortalLayout.tsx b/frontend/src/portal/PortalLayout.tsx index 94a4cd1..cdffb2b 100644 --- a/frontend/src/portal/PortalLayout.tsx +++ b/frontend/src/portal/PortalLayout.tsx @@ -3,10 +3,12 @@ import { useAuth } from "../context/AuthContext"; import { roleLabel } from "../format"; import { APP_VERSION } from "../version"; import ChangePasswordModal from "../components/ChangePasswordModal"; +import TwoFactorModal from "../components/TwoFactorModal"; export default function PortalLayout({ children }: { children: React.ReactNode }) { const { user, logout } = useAuth(); const [changingPw, setChangingPw] = useState(false); + const [managing2fa, setManaging2fa] = useState(false); return (

); } diff --git a/frontend/src/version.ts b/frontend/src/version.ts index a7ad4af..6999382 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ // Bumped each release so the running build is visible in the UI. // If the number shown in the app doesn't match the installed s9pk version, // the new frontend isn't actually being served. -export const APP_VERSION = "0.2.37"; +export const APP_VERSION = "0.2.38";