Files
AstrBot/astrbot/core/utils/totp.py
鸦羽 e8d13af5b9 feat: add TOTP two-factor authentication for dashboard login (#8189)
* feat: add TOTP two-factor authentication for dashboard login

* fix: ensure TOTP verification uses UTC for accurate time comparison

* fix: update recovery code validation logic for disabling TOTP

* test: add unit tests for TOTP functionality and recovery code validation

* chore: format

* feat: add trust_proxy_headers switch for auth rate-limit IP source

* feat: make dashboard auth rate-limit configurable via system settings

Add auth_rate_limit config block to dashboard settings with enable
(default: true), average_interval (default: 1.0s), and max_burst
(default: 3) options. The dashboard auth middleware now reads from
config instead of using hardcoded values. The average_interval and
max_burst fields are conditionally shown only when rate limiting is
enabled.

* fix: normalize dashboard client IP from trusted proxy headers

* refactor: encapsulate rate limiter state into registry, add TTL

* feat: show dynamic page title during two-factor verification

* fix: require two-factor verification for protected config saves

* chore: format

* refactor: reorganize TOTP verification UI components for better layout

* refactor: clean up recovery stage UI by removing unused styles and improving label handling

* docs: add TOTP two-factor authentication documentation for WebUI

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-06-01 16:40:29 +08:00

275 lines
9.2 KiB
Python

from __future__ import annotations
import asyncio
import base64
import datetime
import hashlib
import hmac
import secrets
from enum import Enum
import pyotp
from sqlmodel import col, delete, select
from astrbot.core.db.po import DashboardTrustedDevice
TOTP_TRUSTED_DEVICE_COOKIE_NAME = "astrbot_totp_trusted_device"
TOTP_TRUSTED_DEVICE_MAX_AGE = 30 * 24 * 60 * 60
RECOVERY_CODE_GROUP_COUNT = 4
RECOVERY_CODE_GROUP_LENGTH = 8
RECOVERY_CODE_LENGTH = RECOVERY_CODE_GROUP_COUNT * RECOVERY_CODE_GROUP_LENGTH
_RECOVERY_CODE_KDF_ITERATIONS = 600_000
_RECOVERY_CODE_KDF_SALT_BYTES = 16
_RECOVERY_CODE_KDF_ALGORITHM = "pbkdf2_sha256"
_last_totp_timecode: dict[str, int] = {}
_totp_replay_lock = asyncio.Lock()
_totp_pending_secret: str | None = (
None # pending new secret after rotation, before config save
)
_totp_rotation_verified: bool = (
False # user passed the current-TOTP verify step during rotation
)
class TwoFactorCodeType(Enum):
TOTP = "totp"
RECOVERY = "recovery"
def _get_totp_config(config) -> dict:
totp_config = config.get("dashboard", {}).get("totp", {})
return totp_config if isinstance(totp_config, dict) else {}
def is_totp_enabled(config) -> bool:
"""TOTP is fully configured and operational (enable + secret + recovery hash all present)."""
totp_config = _get_totp_config(config)
if not totp_config.get("enable", False):
return False
secret = totp_config.get("secret", "")
if not isinstance(secret, str) or not secret.strip():
return False
recovery_code_hash = totp_config.get("recovery_code_hash", "")
if not isinstance(recovery_code_hash, str) or not recovery_code_hash.strip():
return False
return True
def _get_verified_totp_timecode(secret: str, code: str) -> int | None:
code = code.strip()
try:
totp = pyotp.TOTP(secret.strip())
now = datetime.datetime.now(datetime.timezone.utc)
for offset in (-1, 0, 1):
candidate_time = now + datetime.timedelta(seconds=offset * totp.interval)
if hmac.compare_digest(str(totp.at(candidate_time)), code):
return int(totp.timecode(candidate_time))
except Exception:
return None
return None
async def consume_totp_code(secret: str, code: str) -> bool:
global _last_totp_timecode
timecode = _get_verified_totp_timecode(secret, code)
if timecode is None:
return False
secret = secret.strip()
async with _totp_replay_lock:
if _last_totp_timecode.get(secret, -1) >= timecode:
return False
_last_totp_timecode[secret] = timecode
return True
async def consume_configured_totp_code(config, code: str) -> bool:
if not is_totp_enabled(config):
return False
secret = _get_totp_config(config).get("secret", "")
return await consume_totp_code(secret, code)
async def verify_configured_2fa_code(
config, code: str, include_pending: bool = False, allow_recovery: bool = False
) -> TwoFactorCodeType | None:
"""Return a 2FA code type when a configured code is valid.
When include_pending is True, also checks the in-memory pending TOTP
secret from an active rotation (used by config-save verification).
When allow_recovery is False, only TOTP codes are accepted (recovery
codes are rejected to prevent privilege escalation on sensitive ops).
"""
if not isinstance(code, str) or not code.strip():
return None
if await consume_configured_totp_code(config, code):
return TwoFactorCodeType.TOTP
if include_pending:
pending = _totp_pending_secret
if pending and await consume_totp_code(pending, code):
return TwoFactorCodeType.TOTP
if allow_recovery and verify_recovery_code(config, code):
return TwoFactorCodeType.RECOVERY
return None
def set_pending_totp_secret(secret: str | None) -> None:
"""Set the pending TOTP secret for an in-memory rotation.
After a successful TOTP rotation, the new secret is stored in memory
so that the subsequent config save 2FA check can verify against it.
Cleared once the config save completes.
"""
global _totp_pending_secret
_totp_pending_secret = secret
def set_rotation_verified(value: bool) -> None:
"""Set or clear the rotation-verified flag."""
global _totp_rotation_verified
_totp_rotation_verified = value
def consume_rotation_verified() -> bool:
"""Check and consume the rotation-verified flag (single-use).
Returns True if the user has passed the old-key verification step.
"""
global _totp_rotation_verified
if _totp_rotation_verified:
_totp_rotation_verified = False
return True
return False
def _hash_totp_trusted_device_token(config, token: str) -> str:
jwt_secret = config["dashboard"].get("jwt_secret", "")
if not isinstance(jwt_secret, str) or not jwt_secret:
return ""
return hmac.new(
jwt_secret.encode("utf-8"),
token.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _hash_totp_secret(config) -> str:
secret = _get_totp_config(config).get("secret", "")
if not isinstance(secret, str) or not secret.strip():
return ""
return hashlib.sha256(secret.strip().encode("utf-8")).hexdigest()
async def is_totp_trusted_device_valid(config, db, cookie_token: str) -> bool:
if not cookie_token:
return False
token_hash = _hash_totp_trusted_device_token(config, cookie_token)
totp_secret_hash = _hash_totp_secret(config)
if not token_hash or not totp_secret_hash:
return False
await _cleanup_expired_totp_trusted_devices(db)
async with db.get_db() as session:
result = await session.execute(
select(DashboardTrustedDevice).where(
col(DashboardTrustedDevice.token_hash) == token_hash,
col(DashboardTrustedDevice.totp_secret_hash) == totp_secret_hash,
col(DashboardTrustedDevice.expires_at)
> datetime.datetime.now(datetime.timezone.utc),
)
)
return result.scalar_one_or_none() is not None
async def issue_totp_trusted_device(config, db) -> str | None:
"""Issue a trusted device token, save to DB, and return the raw token for cookie."""
raw_token = secrets.token_urlsafe(48)
token_hash = _hash_totp_trusted_device_token(config, raw_token)
totp_secret_hash = _hash_totp_secret(config)
if not token_hash or not totp_secret_hash:
return None
expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
seconds=TOTP_TRUSTED_DEVICE_MAX_AGE
)
async with db.get_db() as session:
async with session.begin():
await session.execute(
delete(DashboardTrustedDevice).where(
col(DashboardTrustedDevice.token_hash) == token_hash
)
)
trusted_device = DashboardTrustedDevice.model_validate(
{
"token_hash": token_hash,
"totp_secret_hash": totp_secret_hash,
"expires_at": expires_at,
}
)
session.add(trusted_device)
return raw_token
async def _cleanup_expired_totp_trusted_devices(db) -> None:
async with db.get_db() as session:
async with session.begin():
await session.execute(
delete(DashboardTrustedDevice).where(
col(DashboardTrustedDevice.expires_at)
<= datetime.datetime.now(datetime.timezone.utc)
)
)
async def revoke_user_trusted_devices(db) -> None:
async with db.get_db() as session:
async with session.begin():
await session.execute(delete(DashboardTrustedDevice))
def generate_recovery_code() -> tuple[str, str]:
raw = secrets.token_bytes(20)
recovery_code = base64.b32encode(raw).decode("ascii").rstrip("=")
salt = secrets.token_hex(_RECOVERY_CODE_KDF_SALT_BYTES)
digest = hashlib.pbkdf2_hmac(
"sha256",
recovery_code.encode("utf-8"),
bytes.fromhex(salt),
_RECOVERY_CODE_KDF_ITERATIONS,
).hex()
kdf_hash = f"{_RECOVERY_CODE_KDF_ALGORITHM}${_RECOVERY_CODE_KDF_ITERATIONS}${salt}${digest}"
parts = [
recovery_code[i : i + RECOVERY_CODE_GROUP_LENGTH]
for i in range(0, len(recovery_code), RECOVERY_CODE_GROUP_LENGTH)
]
return "-".join(parts), kdf_hash
def verify_recovery_code(config, code: str) -> bool:
"""Verify a recovery code against configured recovery_code_hash (PBKDF2)."""
cleaned = "".join(char for char in code.upper() if char.isalnum())
if len(cleaned) != RECOVERY_CODE_LENGTH:
return False
totp_config = _get_totp_config(config)
stored_hash = totp_config.get("recovery_code_hash", "")
if not isinstance(stored_hash, str) or not stored_hash:
return False
parts = stored_hash.split("$")
if len(parts) != 4 or parts[0] != _RECOVERY_CODE_KDF_ALGORITHM:
return False
try:
iterations = int(parts[1])
salt = parts[2]
expected_digest = parts[3]
except (ValueError, IndexError):
return False
candidate = hashlib.pbkdf2_hmac(
"sha256",
cleaned.encode("utf-8"),
bytes.fromhex(salt),
iterations,
).hex()
return hmac.compare_digest(candidate, expected_digest)