0.2.38: optional two-factor authentication (authenticator-app TOTP)

Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-11 22:08:04 +02:00
co-authored by Claude Fable 5
parent 053bfeab23
commit 0822eca887
20 changed files with 846 additions and 41 deletions
@@ -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')
+2
View File
@@ -15,6 +15,8 @@ dependencies = [
"starlette-session==0.4.3", "starlette-session==0.4.3",
"openpyxl==3.1.5", "openpyxl==3.1.5",
"msoffcrypto-tool==6.0.0", "msoffcrypto-tool==6.0.0",
"pyotp==2.9.0",
"qrcode==8.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+28
View File
@@ -87,6 +87,28 @@ def reset_password(args: argparse.Namespace) -> None:
print(f"Password reset for {user.name} ({user.username}).") 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: def enable_investor_logins(args: argparse.Namespace) -> None:
"""Give every no-login investor account the shared default password and enable sign-in. """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("--email", required=False, default=None)
reset.add_argument("--password", required=True) 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("list-users", help="List all user accounts")
sub.add_parser( sub.add_parser(
@@ -357,6 +383,8 @@ def main() -> None:
create_user(args) create_user(args)
elif args.command == "reset-password": elif args.command == "reset-password":
reset_password(args) reset_password(args)
elif args.command == "reset-2fa":
reset_2fa(args)
elif args.command == "list-users": elif args.command == "list-users":
list_users(args) list_users(args)
elif args.command == "enable-investor-logins": elif args.command == "enable-investor-logins":
+6
View File
@@ -79,6 +79,12 @@ class User(SQLModel, table=True):
# When this investor last loaded their documents list — docs newer than this get a "New" # 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). # badge in the portal. Null until their first visit (nothing badged for brand-new logins).
docs_seen_at: datetime | None = Field(default=None) 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) created_at: datetime = Field(default_factory=datetime.utcnow)
+129 -3
View File
@@ -2,22 +2,38 @@
import os import os
import secrets import secrets
import time
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select 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.auth import get_current_user, hash_password, verify_password
from ten31portal.database import get_session from ten31portal.database import get_session
from ten31portal.models import User from ten31portal.models import User
from ten31portal.ratelimit import SlidingWindowLimiter 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
# Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes. # 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) _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 # 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. # time as a real one — otherwise the timing difference reveals which usernames exist.
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16)) _DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
@@ -28,7 +44,7 @@ def login(
body: LoginRequest, body: LoginRequest,
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> UserResponse: ) -> UserResponse | LoginPending2FA:
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
retry_after = _login_limiter.retry_after(client_ip) retry_after = _login_limiter.retry_after(client_ip)
if retry_after > 0: if retry_after > 0:
@@ -62,11 +78,61 @@ def login(
raise HTTPException(status_code=401, detail="This account does not have a login yet.") raise HTTPException(status_code=401, detail="This account does not have a login yet.")
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled") 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) _login_limiter.reset(client_ip)
request.session["user_id"] = user.id request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True) 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") @router.post("/logout")
def logout(request: Request) -> dict[str, str]: def logout(request: Request) -> dict[str, str]:
request.session.clear() request.session.clear()
@@ -99,3 +165,63 @@ def change_password(
except OSError: except OSError:
pass pass
return {"status": "ok"} 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"}
+28
View File
@@ -26,9 +26,37 @@ class UserResponse(BaseModel):
is_active: bool is_active: bool
is_service_admin: bool = False is_service_admin: bool = False
primary_account_id: int | None = None # set when this account logs in under another primary_account_id: int | None = None # set when this account logs in under another
totp_enabled: bool = False
created_at: datetime 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 --- # --- User administration ---
class UserCreate(BaseModel): class UserCreate(BaseModel):
+71
View File
@@ -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)
+123
View File
@@ -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("<?xml") or "<svg" in setup.json()["qr_svg"]
confirm = client.post(
"/api/auth/totp/confirm", json={"code": pyotp.TOTP(secret).now()}
)
assert confirm.status_code == 200, confirm.text
codes = confirm.json()["recovery_codes"]
assert len(codes) == 8
return secret, codes
def test_enroll_then_login_requires_code(client, session):
make_user(session, username="mp", role=UserRole.approver)
assert client.post("/api/auth/login", json={"login": "mp", "password": "password123"}).status_code == 200
secret, _ = _enroll(client)
client.post("/api/auth/logout")
# Password alone no longer signs in — it parks the session pending the code.
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json() == {"requires_2fa": True}
assert client.get("/api/auth/me").status_code == 401
# Wrong code is rejected; the right code completes sign-in.
bad = client.post("/api/auth/login/verify-totp", json={"code": "000000"})
assert bad.status_code == 401
good = client.post(
"/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()}
)
assert good.status_code == 200, good.text
assert good.json()["username"] == "mp"
assert good.json()["totp_enabled"] is True
assert client.get("/api/auth/me").status_code == 200
def test_recovery_code_works_once(client, session):
make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
_, codes = _enroll(client)
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
first = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
assert first.status_code == 200, first.text
client.post("/api/auth/logout")
# The same recovery code is spent and cannot be used again.
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
again = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
assert again.status_code == 401
other = client.post("/api/auth/login/verify-totp", json={"code": codes[1]})
assert other.status_code == 200
def test_verify_without_pending_login_fails(client, session):
make_user(session, username="mp", role=UserRole.approver)
resp = client.post("/api/auth/login/verify-totp", json={"code": "123456"})
assert resp.status_code == 401
def test_confirm_requires_valid_first_code(client, session):
make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
setup = client.post("/api/auth/totp/setup")
assert setup.status_code == 200
bad = client.post("/api/auth/totp/confirm", json={"code": "000000"})
assert bad.status_code == 400
# Enrollment never completed, so login stays single-step.
client.post("/api/auth/logout")
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json()["username"] == "mp"
def test_disable_restores_single_step_login(client, session):
make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
secret, _ = _enroll(client)
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
client.post("/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()})
wrong = client.post("/api/auth/totp/disable", json={"password": "not-it"})
assert wrong.status_code == 400
ok = client.post("/api/auth/totp/disable", json={"password": "password123"})
assert ok.status_code == 200
client.post("/api/auth/logout")
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json()["username"] == "mp"
def test_admin_style_reset_clears_enrollment(client, session):
"""Clearing the totp fields (what the reset-2fa CLI does) restores password-only login."""
user = make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
_enroll(client)
client.post("/api/auth/logout")
db_user = session.get(User, user.id)
session.refresh(db_user)
assert db_user.totp_enabled is True
db_user.totp_secret = None
db_user.totp_enabled = False
db_user.totp_recovery_codes = None
session.add(db_user)
session.commit()
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json()["username"] == "mp"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ten31portal-startos", "name": "ten31portal-startos",
"version": "0.2.37", "version": "0.2.38",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+49
View File
@@ -177,6 +177,54 @@ const resetPasswordAction = Action.withInput(
}, },
) )
// ============================================
// Action: Reset Two-Factor
// ============================================
const resetTwoFactorInputSpec = InputSpec.of({
username: Value.text({
name: 'Username',
description: 'Username of the account whose two-factor should be cleared (lost phone)',
default: '',
required: true,
placeholder: 'admin',
}),
})
const resetTwoFactorAction = Action.withInput(
'reset-2fa',
{
name: 'Reset Two-Factor',
description:
"Clear a user's two-factor enrollment so they can sign in with just their password (e.g. after losing their authenticator)",
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
resetTwoFactorInputSpec,
async () => ({ 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 // Action: List Users
// ============================================ // ============================================
@@ -500,6 +548,7 @@ const enableInvestorLoginsAction = Action.withoutInput(
export const actions = sdk.Actions.of() export const actions = sdk.Actions.of()
.addAction(createUserAction) .addAction(createUserAction)
.addAction(resetPasswordAction) .addAction(resetPasswordAction)
.addAction(resetTwoFactorAction)
.addAction(showAdminPasswordAction) .addAction(showAdminPasswordAction)
.addAction(listUsersAction) .addAction(listUsersAction)
.addAction(enableInvestorLoginsAction) .addAction(enableInvestorLoginsAction)
+3 -2
View File
@@ -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_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0' import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1' 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_34 } from './v_0_2_34'
import { v_0_2_35 } from './v_0_2_35' import { v_0_2_35 } from './v_0_2_35'
import { v_0_2_36 } from './v_0_2_36' 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]
@@ -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 }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever) // - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached // - /api/* is never cached
// Bump CACHE on each release so old entries are purged. // 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()) self.addEventListener('install', () => self.skipWaiting())
+30 -1
View File
@@ -44,9 +44,15 @@ export interface User {
is_active: boolean; is_active: boolean;
is_service_admin: boolean; is_service_admin: boolean;
primary_account_id: number | null; primary_account_id: number | null;
totp_enabled: boolean;
created_at: string; 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 { export interface LinkedAccount {
id: number; id: number;
name: string; name: string;
@@ -279,11 +285,17 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
export const api = { export const api = {
login: (login: string, password: string) => login: (login: string, password: string) =>
request<User>("/api/auth/login", { request<User | LoginPending2FA>("/api/auth/login", {
method: "POST", method: "POST",
body: JSON.stringify({ login, password }), body: JSON.stringify({ login, password }),
}), }),
verifyTotp: (code: string) =>
request<User>("/api/auth/login/verify-totp", {
method: "POST",
body: JSON.stringify({ code }),
}),
logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }), logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }),
me: () => request<User>("/api/auth/me"), me: () => request<User>("/api/auth/me"),
@@ -294,6 +306,23 @@ export const api = {
body: JSON.stringify({ current_password, new_password }), 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 // Entities
listEntities: () => request<Entity[]>("/api/entities"), listEntities: () => request<Entity[]>("/api/entities"),
listEntityRollup: () => listEntityRollup: () =>
+9
View File
@@ -5,6 +5,7 @@ import { isAdmin } from "../api";
import { roleLabel } from "../format"; import { roleLabel } from "../format";
import { APP_VERSION } from "../version"; import { APP_VERSION } from "../version";
import ChangePasswordModal from "./ChangePasswordModal"; import ChangePasswordModal from "./ChangePasswordModal";
import TwoFactorModal from "./TwoFactorModal";
const TOP_NAV = [ const TOP_NAV = [
{ label: "Entities", path: "/" }, { label: "Entities", path: "/" },
@@ -26,6 +27,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const location = useLocation(); const location = useLocation();
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [changingPw, setChangingPw] = useState(false); const [changingPw, setChangingPw] = useState(false);
const [managing2fa, setManaging2fa] = useState(false);
const navItems = user && isAdmin(user.role) const navItems = user && isAdmin(user.role)
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV] ? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
: [...TOP_NAV, ...BOTTOM_NAV]; : [...TOP_NAV, ...BOTTOM_NAV];
@@ -83,6 +85,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
> >
Change password Change password
</button> </button>
<button
onClick={() => { setManaging2fa(true); setNavOpen(false); }}
className="block text-sm text-gray-600 hover:text-gray-900"
>
Two-factor auth
</button>
<div className="text-xs text-gray-400">v{APP_VERSION}</div> <div className="text-xs text-gray-400">v{APP_VERSION}</div>
</div> </div>
</nav> </nav>
@@ -122,6 +130,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div> </div>
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />} {changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
{managing2fa && <TwoFactorModal onClose={() => setManaging2fa(false)} />}
</div> </div>
); );
} }
+209
View File
@@ -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<string[] | null>(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 (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={close}>
<div
className="bg-white rounded-lg shadow-lg w-full max-w-md p-6 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Two-factor authentication</h3>
{recoveryCodes ? (
<div className="space-y-4">
<p className="text-sm text-green-700">Two-factor authentication is now enabled.</p>
<div>
<p className="text-sm text-gray-700 mb-2">
Save these recovery codes somewhere safe. Each works once if you lose your
authenticator <span className="font-medium">they will not be shown again</span>.
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 bg-gray-50 border border-gray-200 rounded p-3 font-mono text-xs text-gray-800">
{recoveryCodes.map((c) => (
<span key={c}>{c}</span>
))}
</div>
</div>
<div className="flex justify-end">
<button onClick={close} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800">
I've saved my recovery codes
</button>
</div>
</div>
) : setup ? (
<div className="space-y-4">
<p className="text-sm text-gray-700">
Scan this QR code with an authenticator app (Google Authenticator, 1Password,
Authy…), then enter the 6-digit code it shows.
</p>
<div className="flex justify-center">
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(setup.qr_svg)}`}
alt="Two-factor enrollment QR code"
className="w-44 h-44 border border-gray-200 rounded"
/>
</div>
<p className="text-xs text-gray-400 text-center break-all">
Can't scan? Enter this key manually: <span className="font-mono">{setup.secret}</span>
</p>
<div>
<label className="block text-sm text-gray-700 mb-1">Code from your app</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2">
<button onClick={close} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
Cancel
</button>
<button
onClick={confirm}
disabled={busy || code.trim().length < 6}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Checking…" : "Turn on"}
</button>
</div>
</div>
) : enabled ? (
<div className="space-y-4">
<p className="text-sm text-gray-700">
<span className="inline-block w-2 h-2 rounded-full bg-green-500 mr-1.5 align-middle" />
Two-factor authentication is <span className="font-medium">on</span>. Signing in
requires a code from your authenticator app.
</p>
{disabling ? (
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-700 mb-1">
Confirm your password to turn it off
</label>
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2">
<button
onClick={() => { setDisabling(false); setPassword(""); setError(""); }}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button
onClick={disable}
disabled={busy || !password}
className="px-3 py-1.5 bg-red-700 text-white text-sm rounded hover:bg-red-800 disabled:opacity-50"
>
{busy ? "Turning off…" : "Turn off two-factor"}
</button>
</div>
</div>
) : (
<div className="flex justify-end gap-2">
<button onClick={close} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
Close
</button>
<button
onClick={() => setDisabling(true)}
className="px-3 py-1.5 text-sm text-red-700 border border-red-200 rounded hover:bg-red-50"
>
Turn off
</button>
</div>
)}
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-gray-700">
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.
</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2">
<button onClick={close} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
Close
</button>
<button
onClick={start}
disabled={busy}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Preparing…" : "Set up two-factor"}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+15 -4
View File
@@ -6,7 +6,10 @@ interface AuthState {
loading: boolean; loading: boolean;
offline: boolean; // couldn't reach the server (vs. genuinely logged out) offline: boolean; // couldn't reach the server (vs. genuinely logged out)
retry: () => void; retry: () => void;
login: (handle: string, password: string) => Promise<void>; /** 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<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -34,8 +37,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { bootstrap(); }, [bootstrap]); useEffect(() => { bootstrap(); }, [bootstrap]);
const login = async (handle: string, password: string) => { const login = async (handle: string, password: string): Promise<"ok" | "2fa"> => {
const u = await api.login(handle, password); 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); setUser(u);
setOffline(false); setOffline(false);
}; };
@@ -46,7 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
return ( return (
<AuthContext.Provider value={{ user, loading, offline, retry: bootstrap, login, logout }}> <AuthContext.Provider value={{ user, loading, offline, retry: bootstrap, login, verifyTotp, logout }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+86 -27
View File
@@ -3,9 +3,11 @@ import { useAuth } from "../context/AuthContext";
import PasswordInput from "../components/PasswordInput"; import PasswordInput from "../components/PasswordInput";
export default function Login() { export default function Login() {
const { login } = useAuth(); const { login, verifyTotp } = useAuth();
const [handle, setHandle] = useState(""); const [handle, setHandle] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [needsCode, setNeedsCode] = useState(false);
const [code, setCode] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -14,7 +16,8 @@ export default function Login() {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
await login(handle, password); const result = await login(handle, password);
if (result === "2fa") setNeedsCode(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Login failed"); setError(err.message || "Login failed");
} finally { } 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 ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96"> <div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96">
@@ -29,31 +51,68 @@ export default function Login() {
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" /> <img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
<h1 className="text-xl font-semibold text-gray-900">Ten31 Portal</h1> <h1 className="text-xl font-semibold text-gray-900">Ten31 Portal</h1>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> {needsCode ? (
<div> <form onSubmit={handleCodeSubmit} className="space-y-4">
<label className="block text-sm text-gray-700 mb-1">Username or email</label> <div>
<input <label className="block text-sm text-gray-700 mb-1">Two-factor code</label>
type="text" <input
autoComplete="username" type="text"
value={handle} inputMode="numeric"
onChange={(e) => setHandle(e.target.value)} autoComplete="one-time-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" autoFocus
required value={code}
/> onChange={(e) => setCode(e.target.value)}
</div> placeholder="6-digit code"
<div> 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"
<label className="block text-sm text-gray-700 mb-1">Password</label> required
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" /> />
</div> <p className="mt-1.5 text-xs text-gray-400">
{error && <p className="text-sm text-red-600">{error}</p>} Enter the code from your authenticator app, or one of your recovery codes.
<button </p>
type="submit" </div>
disabled={loading} {error && <p className="text-sm text-red-600">{error}</p>}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50" <button
> type="submit"
{loading ? "Signing in..." : "Sign in"} disabled={loading}
</button> className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
</form> >
{loading ? "Verifying..." : "Verify"}
</button>
<button
type="button"
onClick={() => { setNeedsCode(false); setCode(""); setError(""); }}
className="w-full text-xs text-gray-400 hover:text-gray-600"
>
Back to sign in
</button>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
<input
type="text"
autoComplete="username"
value={handle}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Password</label>
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
)}
<p className="mt-5 text-xs text-gray-400 text-center"> <p className="mt-5 text-xs text-gray-400 text-center">
Trouble signing in?{" "} Trouble signing in?{" "}
<a href="mailto:Portal@ten31.xyz" className="text-accent-600 hover:text-accent-700"> <a href="mailto:Portal@ten31.xyz" className="text-accent-600 hover:text-accent-700">
+9 -1
View File
@@ -3,10 +3,12 @@ import { useAuth } from "../context/AuthContext";
import { roleLabel } from "../format"; import { roleLabel } from "../format";
import { APP_VERSION } from "../version"; import { APP_VERSION } from "../version";
import ChangePasswordModal from "../components/ChangePasswordModal"; import ChangePasswordModal from "../components/ChangePasswordModal";
import TwoFactorModal from "../components/TwoFactorModal";
export default function PortalLayout({ children }: { children: React.ReactNode }) { export default function PortalLayout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [changingPw, setChangingPw] = useState(false); const [changingPw, setChangingPw] = useState(false);
const [managing2fa, setManaging2fa] = useState(false);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
@@ -19,7 +21,12 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
{user ? roleLabel(user.role) : ""} {user ? roleLabel(user.role) : ""}
</span> </span>
<button onClick={() => setChangingPw(true)} className="text-sm text-gray-500 hover:text-gray-800"> <button onClick={() => setChangingPw(true)} className="text-sm text-gray-500 hover:text-gray-800">
Change password <span className="sm:hidden">Password</span>
<span className="hidden sm:inline">Change password</span>
</button>
<button onClick={() => setManaging2fa(true)} className="text-sm text-gray-500 hover:text-gray-800">
<span className="sm:hidden">2FA</span>
<span className="hidden sm:inline">Two-factor</span>
</button> </button>
<button onClick={logout} className="text-sm text-gray-500 hover:text-gray-800"> <button onClick={logout} className="text-sm text-gray-500 hover:text-gray-800">
Sign out Sign out
@@ -29,6 +36,7 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
</header> </header>
<main className="max-w-4xl mx-auto p-4 sm:p-6">{children}</main> <main className="max-w-4xl mx-auto p-4 sm:p-6">{children}</main>
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />} {changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
{managing2fa && <TwoFactorModal onClose={() => setManaging2fa(false)} />}
</div> </div>
); );
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI. // 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, // If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served. // the new frontend isn't actually being served.
export const APP_VERSION = "0.2.37"; export const APP_VERSION = "0.2.38";