mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* 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>
275 lines
9.2 KiB
Python
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)
|