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:
co-authored by
Claude Fable 5
parent
053bfeab23
commit
0822eca887
@@ -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')
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
@@ -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: () =>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user