mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
refactor(auth): merge passkey challenge helper (#6179)
This commit is contained in:
@@ -22,8 +22,8 @@ from app.helper.passkey import (
|
|||||||
PassKeyHelper,
|
PassKeyHelper,
|
||||||
PassKeyRegistrationOriginMismatchError,
|
PassKeyRegistrationOriginMismatchError,
|
||||||
PassKeyRegistrationVerificationError,
|
PassKeyRegistrationVerificationError,
|
||||||
|
PasskeyChallengeStore,
|
||||||
)
|
)
|
||||||
from app.helper.passkey_challenge import PasskeyChallengeStore
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.otp import OtpUtils
|
from app.utils.otp import OtpUtils
|
||||||
|
|||||||
+79
-2
@@ -2,9 +2,12 @@
|
|||||||
PassKey WebAuthn 辅助工具类
|
PassKey WebAuthn 辅助工具类
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import json
|
|
||||||
import binascii
|
import binascii
|
||||||
from typing import Optional, Tuple, List, Dict, Any
|
import json
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from webauthn import (
|
from webauthn import (
|
||||||
@@ -28,9 +31,83 @@ from webauthn.helpers.structs import (
|
|||||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
||||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
||||||
|
|
||||||
|
from app.core.cache import TTLCache
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.helper.redis import RedisHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
|
||||||
|
PasskeyChallengePurpose = Literal["authentication", "registration"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PasskeyChallenge:
|
||||||
|
"""服务端保存的一次性 Passkey challenge 及其认证边界。"""
|
||||||
|
|
||||||
|
challenge: str
|
||||||
|
purpose: PasskeyChallengePurpose
|
||||||
|
user_id: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyChallengeStore:
|
||||||
|
"""使用当前缓存后端签发并原子消费短时 Passkey challenge。"""
|
||||||
|
|
||||||
|
_cache = TTLCache(
|
||||||
|
region="passkey_challenge",
|
||||||
|
maxsize=4096,
|
||||||
|
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
_memory_consume_lock = threading.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def issue(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
challenge: str,
|
||||||
|
purpose: PasskeyChallengePurpose,
|
||||||
|
user_id: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
"""保存 challenge 并返回不携带认证事实的随机事务 token。"""
|
||||||
|
transaction_token = secrets.token_urlsafe(32)
|
||||||
|
cls._cache.set(
|
||||||
|
transaction_token,
|
||||||
|
PasskeyChallenge(
|
||||||
|
challenge=challenge,
|
||||||
|
purpose=purpose,
|
||||||
|
user_id=user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return transaction_token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def consume(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
transaction_token: str,
|
||||||
|
purpose: PasskeyChallengePurpose,
|
||||||
|
) -> Optional[PasskeyChallenge]:
|
||||||
|
"""原子领取 challenge;任何完成尝试都会使事务失效。"""
|
||||||
|
if not transaction_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if cls._cache.is_redis():
|
||||||
|
challenge = RedisHelper().pop(
|
||||||
|
transaction_token,
|
||||||
|
region="passkey_challenge",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with cls._memory_consume_lock:
|
||||||
|
try:
|
||||||
|
challenge = cls._cache.pop(transaction_token)
|
||||||
|
except KeyError:
|
||||||
|
challenge = None
|
||||||
|
|
||||||
|
if not isinstance(challenge, PasskeyChallenge):
|
||||||
|
return None
|
||||||
|
if challenge.purpose != purpose:
|
||||||
|
return None
|
||||||
|
return challenge
|
||||||
|
|
||||||
|
|
||||||
class PassKeyRegistrationVerificationError(Exception):
|
class PassKeyRegistrationVerificationError(Exception):
|
||||||
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
|
"""Passkey 注册响应未通过 WebAuthn 安全校验。"""
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import secrets
|
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from app.core.cache import TTLCache
|
|
||||||
from app.helper.redis import RedisHelper
|
|
||||||
|
|
||||||
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
|
|
||||||
PasskeyChallengePurpose = Literal["authentication", "registration"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PasskeyChallenge:
|
|
||||||
"""服务端保存的一次性 Passkey challenge 及其认证边界。"""
|
|
||||||
|
|
||||||
challenge: str
|
|
||||||
purpose: PasskeyChallengePurpose
|
|
||||||
user_id: Optional[int]
|
|
||||||
|
|
||||||
|
|
||||||
class PasskeyChallengeStore:
|
|
||||||
"""使用当前缓存后端签发并原子消费短时 Passkey challenge。"""
|
|
||||||
|
|
||||||
_cache = TTLCache(
|
|
||||||
region="passkey_challenge",
|
|
||||||
maxsize=4096,
|
|
||||||
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
|
|
||||||
)
|
|
||||||
_memory_consume_lock = threading.Lock()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def issue(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
challenge: str,
|
|
||||||
purpose: PasskeyChallengePurpose,
|
|
||||||
user_id: Optional[int],
|
|
||||||
) -> str:
|
|
||||||
"""保存 challenge 并返回不携带认证事实的随机事务 token。"""
|
|
||||||
transaction_token = secrets.token_urlsafe(32)
|
|
||||||
cls._cache.set(
|
|
||||||
transaction_token,
|
|
||||||
PasskeyChallenge(
|
|
||||||
challenge=challenge,
|
|
||||||
purpose=purpose,
|
|
||||||
user_id=user_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return transaction_token
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def consume(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
transaction_token: str,
|
|
||||||
purpose: PasskeyChallengePurpose,
|
|
||||||
) -> Optional[PasskeyChallenge]:
|
|
||||||
"""原子领取 challenge;任何完成尝试都会使事务失效。"""
|
|
||||||
if not transaction_token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if cls._cache.is_redis():
|
|
||||||
challenge = RedisHelper().pop(
|
|
||||||
transaction_token,
|
|
||||||
region="passkey_challenge",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
with cls._memory_consume_lock:
|
|
||||||
try:
|
|
||||||
challenge = cls._cache.pop(transaction_token)
|
|
||||||
except KeyError:
|
|
||||||
challenge = None
|
|
||||||
|
|
||||||
if not isinstance(challenge, PasskeyChallenge):
|
|
||||||
return None
|
|
||||||
if challenge.purpose != purpose:
|
|
||||||
return None
|
|
||||||
return challenge
|
|
||||||
@@ -10,8 +10,8 @@ from app.helper.passkey import (
|
|||||||
PassKeyHelper,
|
PassKeyHelper,
|
||||||
PassKeyRegistrationOriginMismatchError,
|
PassKeyRegistrationOriginMismatchError,
|
||||||
PassKeyRegistrationVerificationError,
|
PassKeyRegistrationVerificationError,
|
||||||
|
PasskeyChallengeStore,
|
||||||
)
|
)
|
||||||
from app.helper.passkey_challenge import PasskeyChallengeStore
|
|
||||||
|
|
||||||
|
|
||||||
def _registration_request(user_id: int = 1) -> mfa_endpoint.PassKeyRegistrationFinish:
|
def _registration_request(user_id: int = 1) -> mfa_endpoint.PassKeyRegistrationFinish:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from app.api.endpoints import mfa as mfa_endpoint
|
from app.api.endpoints import mfa as mfa_endpoint
|
||||||
from app.helper.passkey_challenge import PasskeyChallengeStore
|
from app.helper.passkey import PasskeyChallengeStore
|
||||||
|
|
||||||
|
|
||||||
def _request() -> Request:
|
def _request() -> Request:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from app.core.cache import TTLCache
|
from app.core.cache import TTLCache
|
||||||
from app.helper.passkey_challenge import PasskeyChallengeStore
|
from app.helper.passkey import PasskeyChallengeStore
|
||||||
|
|
||||||
|
|
||||||
def setup_function():
|
def setup_function():
|
||||||
|
|||||||
Reference in New Issue
Block a user