mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
531 lines
17 KiB
Python
531 lines
17 KiB
Python
"""
|
|
MFA (Multi-Factor Authentication) API 端点
|
|
包含 OTP 和 PassKey 相关功能
|
|
"""
|
|
|
|
import json
|
|
from datetime import timedelta
|
|
from typing import Any, Annotated, Optional
|
|
|
|
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
|
from fastapi import Depends, HTTPException, Body, Request, Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app import schemas
|
|
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
|
from app.application.security import access as security
|
|
from app.runtime.config import settings
|
|
from app.db import get_async_db
|
|
from app.db.models.passkey import PassKey
|
|
from app.db.models.user import User
|
|
from app.db.oper.systemconfig import SystemConfigOper
|
|
from app.api.deps import get_current_active_user, get_current_active_user_async
|
|
from app.application.security.passkey import (
|
|
PassKeyHelper,
|
|
PassKeyRegistrationOriginMismatchError,
|
|
PassKeyRegistrationVerificationError,
|
|
PasskeyChallengeStore,
|
|
)
|
|
from app.runtime.log import logger
|
|
from app.schemas.types import SystemConfigKey
|
|
from app.application.security.otp import OtpUtils
|
|
|
|
router = ResponseAPIRouter()
|
|
|
|
# ==================== 辅助函数 ====================
|
|
|
|
|
|
def _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]:
|
|
"""
|
|
构建凭证列表
|
|
|
|
:param passkeys: PassKey 列表
|
|
:return: 凭证字典列表
|
|
"""
|
|
return (
|
|
[
|
|
{"credential_id": pk.credential_id, "transports": pk.transports}
|
|
for pk in passkeys
|
|
]
|
|
if passkeys
|
|
else []
|
|
)
|
|
|
|
|
|
def _extract_and_standardize_credential_id(credential: dict) -> str:
|
|
"""
|
|
从凭证中提取并标准化 credential_id
|
|
|
|
:param credential: 凭证字典
|
|
:return: 标准化后的 credential_id
|
|
:raises ValueError: 如果凭证无效
|
|
"""
|
|
credential_id_raw = credential.get("id") or credential.get("rawId")
|
|
if not credential_id_raw:
|
|
raise ValueError("无效的凭证")
|
|
return PassKeyHelper.standardize_credential_id(credential_id_raw)
|
|
|
|
|
|
def _verify_passkey_and_update(
|
|
credential: dict, challenge: str, passkey: PassKey
|
|
) -> tuple[bool, int]:
|
|
"""
|
|
验证 PassKey 并更新使用时间和签名计数
|
|
|
|
:param credential: 凭证字典
|
|
:param challenge: 挑战值
|
|
:param passkey: PassKey 对象
|
|
:return: (验证是否成功, 新的签名计数)
|
|
"""
|
|
success, new_sign_count = PassKeyHelper.verify_authentication_response(
|
|
credential=credential,
|
|
expected_challenge=challenge,
|
|
credential_public_key=passkey.public_key,
|
|
credential_current_sign_count=passkey.sign_count,
|
|
)
|
|
|
|
if success:
|
|
passkey.update_last_used(db=None, sign_count=new_sign_count)
|
|
|
|
return success, new_sign_count
|
|
|
|
|
|
# ==================== 请求模型 ====================
|
|
|
|
|
|
class OtpVerifyRequest(schemas.BaseModel):
|
|
"""OTP验证请求"""
|
|
|
|
uri: str
|
|
otpPassword: str
|
|
|
|
|
|
class OtpDisableRequest(schemas.BaseModel):
|
|
"""OTP禁用请求"""
|
|
|
|
password: str
|
|
|
|
|
|
class PassKeyDeleteRequest(schemas.BaseModel):
|
|
"""PassKey删除请求"""
|
|
|
|
passkey_id: int
|
|
password: str
|
|
|
|
|
|
# ==================== 通用 MFA 接口 ====================
|
|
|
|
|
|
@router.get(
|
|
"/status/{username}",
|
|
summary="判断用户是否开启二次验证",
|
|
response_model=schemas.Response[schemas.MfaStatusData],
|
|
)
|
|
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
|
|
"""
|
|
检查指定用户是否启用了二次验证
|
|
"""
|
|
user: User = await User.async_get_by_name(db, username)
|
|
if not user:
|
|
return schemas.Response(success=False, message="用户不存在")
|
|
|
|
# 检查是否启用了OTP
|
|
has_otp = user.is_otp
|
|
|
|
return schemas.Response(success=True, data={"enabled": bool(has_otp)})
|
|
|
|
|
|
# ==================== OTP 相关接口 ====================
|
|
|
|
|
|
@router.post(
|
|
"/otp/generate",
|
|
summary="生成 OTP 验证 URI",
|
|
response_model=schemas.Response[schemas.OtpGenerateData],
|
|
)
|
|
def otp_generate(
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> Any:
|
|
"""生成 OTP 密钥及对应的 URI"""
|
|
secret, uri = OtpUtils.generate_secret_key(current_user.name)
|
|
return schemas.Response(success=secret != "", data={"secret": secret, "uri": uri})
|
|
|
|
|
|
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response[None])
|
|
async def otp_verify(
|
|
data: OtpVerifyRequest,
|
|
db: AsyncSession = Depends(get_async_db),
|
|
current_user: User = Depends(get_current_active_user_async),
|
|
) -> Any:
|
|
"""验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证"""
|
|
if not OtpUtils.is_legal(data.uri, data.otpPassword):
|
|
return schemas.Response(success=False, message="验证码错误")
|
|
await current_user.async_update_otp_by_name(
|
|
db, current_user.name, True, OtpUtils.get_secret(data.uri)
|
|
)
|
|
return schemas.Response(success=True)
|
|
|
|
|
|
@router.post(
|
|
"/otp/disable",
|
|
summary="关闭当前用户的 OTP 验证",
|
|
response_model=schemas.Response[None],
|
|
)
|
|
async def otp_disable(
|
|
data: OtpDisableRequest,
|
|
db: AsyncSession = Depends(get_async_db),
|
|
current_user: User = Depends(get_current_active_user_async),
|
|
) -> Any:
|
|
"""关闭当前用户的 OTP 验证功能"""
|
|
# 验证密码
|
|
if not security.verify_password(data.password, str(current_user.hashed_password)):
|
|
return schemas.Response(success=False, message="密码错误")
|
|
await current_user.async_update_otp_by_name(db, current_user.name, False, "")
|
|
return schemas.Response(success=True)
|
|
|
|
|
|
# ==================== PassKey 相关接口 ====================
|
|
|
|
|
|
class PassKeyRegistrationStart(schemas.BaseModel):
|
|
"""PassKey注册开始请求"""
|
|
|
|
name: str = "通行密钥"
|
|
|
|
|
|
class PassKeyRegistrationFinish(schemas.BaseModel):
|
|
"""PassKey注册完成请求"""
|
|
|
|
credential: dict[str, schemas.JsonData]
|
|
transaction_token: str
|
|
name: str = "通行密钥"
|
|
|
|
|
|
class PassKeyAuthenticationStart(schemas.BaseModel):
|
|
"""PassKey认证开始请求"""
|
|
|
|
username: Optional[str] = None
|
|
|
|
|
|
class PassKeyAuthenticationFinish(schemas.BaseModel):
|
|
"""PassKey认证完成请求"""
|
|
|
|
credential: dict[str, schemas.JsonData]
|
|
transaction_token: str
|
|
|
|
|
|
@router.post(
|
|
"/passkey/register/start",
|
|
summary="开始注册 PassKey",
|
|
response_model=schemas.Response[schemas.PasskeyStartData],
|
|
)
|
|
def passkey_register_start(
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> Any:
|
|
"""开始注册 PassKey - 生成注册选项"""
|
|
try:
|
|
# 获取用户已有的PassKey
|
|
existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)
|
|
existing_credentials = (
|
|
_build_credential_list(existing_passkeys) if existing_passkeys else None
|
|
)
|
|
|
|
# 生成注册选项
|
|
options_json, challenge = PassKeyHelper.generate_registration_options(
|
|
user_id=current_user.id,
|
|
username=current_user.name,
|
|
display_name=current_user.settings.get("nickname")
|
|
if current_user.settings
|
|
else None,
|
|
existing_credentials=existing_credentials,
|
|
)
|
|
|
|
transaction_token = PasskeyChallengeStore.issue(
|
|
challenge=challenge,
|
|
purpose="registration",
|
|
user_id=current_user.id,
|
|
)
|
|
return schemas.Response(
|
|
success=True,
|
|
data={
|
|
"options": json.loads(options_json),
|
|
"transaction_token": transaction_token,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"生成PassKey注册选项失败: {e}")
|
|
return schemas.Response(success=False, message=f"生成注册选项失败: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/passkey/register/finish",
|
|
summary="完成注册 PassKey",
|
|
response_model=schemas.Response[None],
|
|
)
|
|
def passkey_register_finish(
|
|
passkey_req: PassKeyRegistrationFinish,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> Any:
|
|
"""完成注册 PassKey - 验证并保存凭证"""
|
|
try:
|
|
challenge_state = PasskeyChallengeStore.consume(
|
|
transaction_token=passkey_req.transaction_token,
|
|
purpose="registration",
|
|
)
|
|
if not challenge_state or challenge_state.user_id != current_user.id:
|
|
return schemas.Response(
|
|
success=False,
|
|
message="注册请求已失效,请重新发起注册",
|
|
)
|
|
|
|
# 验证注册响应
|
|
credential_id, public_key, sign_count, aaguid = (
|
|
PassKeyHelper.verify_registration_response(
|
|
credential=passkey_req.credential,
|
|
expected_challenge=challenge_state.challenge,
|
|
)
|
|
)
|
|
|
|
# 提取transports
|
|
transports = None
|
|
if (
|
|
"response" in passkey_req.credential
|
|
and "transports" in passkey_req.credential["response"]
|
|
):
|
|
transports = ",".join(passkey_req.credential["response"]["transports"])
|
|
|
|
# 保存到数据库
|
|
passkey = PassKey(
|
|
user_id=current_user.id,
|
|
credential_id=credential_id,
|
|
public_key=public_key,
|
|
sign_count=sign_count,
|
|
name=passkey_req.name or "通行密钥",
|
|
aaguid=aaguid,
|
|
transports=transports,
|
|
)
|
|
passkey.create()
|
|
|
|
logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}")
|
|
|
|
return schemas.Response(success=True, message="通行密钥注册成功")
|
|
except PassKeyRegistrationOriginMismatchError:
|
|
return schemas.Response(
|
|
success=False,
|
|
message="访问域名与系统配置不一致,请使用配置的域名重试",
|
|
)
|
|
except PassKeyRegistrationVerificationError:
|
|
return schemas.Response(
|
|
success=False,
|
|
message="通行密钥注册验证失败,请重新发起注册后重试",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"注册PassKey失败: {e}")
|
|
return schemas.Response(success=False, message="通行密钥注册失败,请稍后重试")
|
|
|
|
|
|
@router.post(
|
|
"/passkey/authenticate/start",
|
|
summary="开始 PassKey 认证",
|
|
response_model=schemas.Response[schemas.PasskeyStartData],
|
|
)
|
|
def passkey_authenticate_start(
|
|
passkey_req: PassKeyAuthenticationStart = Body(...),
|
|
) -> Any:
|
|
"""开始 PassKey 认证 - 生成认证选项"""
|
|
try:
|
|
existing_credentials = None
|
|
user_id = None
|
|
|
|
# 如果指定了用户名,只允许该用户的PassKey
|
|
if passkey_req.username:
|
|
user = User.get_by_name(db=None, name=passkey_req.username)
|
|
existing_passkeys = (
|
|
PassKey.get_by_user_id(db=None, user_id=user.id) if user else None
|
|
)
|
|
|
|
if not user or not existing_passkeys:
|
|
return schemas.Response(success=False, message="认证失败")
|
|
|
|
existing_credentials = _build_credential_list(existing_passkeys)
|
|
user_id = user.id
|
|
|
|
# 生成认证选项
|
|
options_json, challenge = PassKeyHelper.generate_authentication_options(
|
|
existing_credentials=existing_credentials
|
|
)
|
|
|
|
transaction_token = PasskeyChallengeStore.issue(
|
|
challenge=challenge,
|
|
purpose="authentication",
|
|
user_id=user_id,
|
|
)
|
|
return schemas.Response(
|
|
success=True,
|
|
data={
|
|
"options": json.loads(options_json),
|
|
"transaction_token": transaction_token,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"生成PassKey认证选项失败: {e}")
|
|
return schemas.Response(success=False, message="认证失败")
|
|
|
|
|
|
@router.post(
|
|
"/passkey/authenticate/finish",
|
|
summary="完成 PassKey 认证",
|
|
response_model=schemas.Token,
|
|
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
|
)
|
|
def passkey_authenticate_finish(
|
|
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
|
|
) -> Any:
|
|
"""完成 PassKey 认证 - 验证凭证并返回 token"""
|
|
try:
|
|
challenge_state = PasskeyChallengeStore.consume(
|
|
transaction_token=passkey_req.transaction_token,
|
|
purpose="authentication",
|
|
)
|
|
if not challenge_state:
|
|
raise HTTPException(status_code=401, detail="认证请求已失效")
|
|
|
|
# 提取并标准化凭证ID
|
|
try:
|
|
credential_id = _extract_and_standardize_credential_id(
|
|
passkey_req.credential
|
|
)
|
|
except ValueError as e:
|
|
logger.warning(f"PassKey认证失败,提供的凭证无效: {e}")
|
|
raise HTTPException(status_code=401, detail="认证失败")
|
|
|
|
# 查找PassKey并获取用户
|
|
passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id)
|
|
user = User.get_by_id(db=None, user_id=passkey.user_id) if passkey else None
|
|
if not passkey or not user or not user.is_active:
|
|
raise HTTPException(status_code=401, detail="认证失败")
|
|
if challenge_state.user_id is not None and challenge_state.user_id != user.id:
|
|
raise HTTPException(status_code=401, detail="认证失败")
|
|
|
|
# 验证认证响应并更新
|
|
success, _ = _verify_passkey_and_update(
|
|
credential=passkey_req.credential,
|
|
challenge=challenge_state.challenge,
|
|
passkey=passkey,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=401, detail="认证失败")
|
|
|
|
logger.info(f"用户 {user.name} 通过PassKey认证成功")
|
|
|
|
# 生成token
|
|
level = SitesHelper().auth_level
|
|
show_wizard = (
|
|
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
|
|
and not settings.ADVANCED_MODE
|
|
)
|
|
|
|
access_token = security.create_access_token(
|
|
userid=user.id,
|
|
username=user.name,
|
|
super_user=user.is_superuser,
|
|
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
|
level=level,
|
|
)
|
|
security.set_or_refresh_resource_token_cookie(
|
|
request,
|
|
response,
|
|
schemas.TokenPayload(
|
|
sub=user.id,
|
|
username=user.name,
|
|
super_user=user.is_superuser,
|
|
level=level,
|
|
purpose="authentication",
|
|
),
|
|
)
|
|
|
|
return schemas.Token(
|
|
access_token=access_token,
|
|
token_type="bearer",
|
|
super_user=user.is_superuser,
|
|
user_id=user.id,
|
|
user_name=user.name,
|
|
avatar=user.avatar,
|
|
level=level,
|
|
permissions=user.permissions or {},
|
|
wizard=show_wizard,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"PassKey认证失败: {e}")
|
|
raise HTTPException(status_code=401, detail="认证失败")
|
|
|
|
|
|
@router.get(
|
|
"/passkey/list",
|
|
summary="获取当前用户的 PassKey 列表",
|
|
response_model=schemas.Response[list[schemas.PasskeyInfo]],
|
|
)
|
|
def passkey_list(
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> Any:
|
|
"""获取当前用户的所有 PassKey"""
|
|
try:
|
|
passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)
|
|
|
|
key_list = (
|
|
[
|
|
{
|
|
"id": pk.id,
|
|
"name": pk.name,
|
|
"created_at": pk.created_at.isoformat() if pk.created_at else None,
|
|
"last_used_at": pk.last_used_at.isoformat()
|
|
if pk.last_used_at
|
|
else None,
|
|
"aaguid": pk.aaguid,
|
|
"transports": pk.transports,
|
|
}
|
|
for pk in passkeys
|
|
]
|
|
if passkeys
|
|
else []
|
|
)
|
|
|
|
return schemas.Response(success=True, data=key_list)
|
|
except Exception as e:
|
|
logger.error(f"获取PassKey列表失败: {e}")
|
|
return schemas.Response(success=False, message=f"获取列表失败: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/passkey/delete",
|
|
summary="删除 PassKey",
|
|
response_model=schemas.Response[None],
|
|
)
|
|
async def passkey_delete(
|
|
data: PassKeyDeleteRequest,
|
|
current_user: User = Depends(get_current_active_user_async),
|
|
) -> Any:
|
|
"""删除指定的 PassKey"""
|
|
try:
|
|
# 验证密码
|
|
if not security.verify_password(
|
|
data.password, str(current_user.hashed_password)
|
|
):
|
|
return schemas.Response(success=False, message="密码错误")
|
|
|
|
success = PassKey.delete_by_id(
|
|
db=None, passkey_id=data.passkey_id, user_id=current_user.id
|
|
)
|
|
|
|
if success:
|
|
logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}")
|
|
return schemas.Response(success=True, message="通行密钥已删除")
|
|
else:
|
|
return schemas.Response(success=False, message="通行密钥不存在或无权删除")
|
|
except Exception as e:
|
|
logger.error(f"删除PassKey失败: {e}")
|
|
return schemas.Response(success=False, message=f"删除失败: {str(e)}")
|