From 74956e4de2e693732c1f849cb717114506b5d936 Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:16:16 +0800 Subject: [PATCH 1/2] feat(account): implement WebAuthn passkey support --- apps/account/admin.py | 19 +- apps/account/constants.py | 4 + apps/account/exceptions.py | 25 ++ .../migrations/0009_userpasskeycredential.py | 53 +++++ apps/account/models.py | 31 +++ apps/account/passkeys.py | 218 ++++++++++++++++++ apps/account/serializers.py | 41 ++++ apps/account/views.py | 110 +++++++++ entry/settings.py | 10 + env.example | 3 + locale/zh_Hans/LC_MESSAGES/django.po | 75 +++++- requirements.txt | 3 + 12 files changed, 582 insertions(+), 10 deletions(-) create mode 100644 apps/account/migrations/0009_userpasskeycredential.py create mode 100644 apps/account/passkeys.py diff --git a/apps/account/admin.py b/apps/account/admin.py index 4b5a3bf..cc0f63a 100644 --- a/apps/account/admin.py +++ b/apps/account/admin.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy from ovinc_client.core.utils import strtobool -from apps.account.models import User, UserToken +from apps.account.models import User, UserPasskeyCredential, UserToken @admin.register(User) @@ -43,3 +43,20 @@ class UserTokenAdmin(admin.ModelAdmin): @admin.display(description=gettext_lazy("Is Expired"), boolean=True) def is_expired(self, token: UserToken) -> bool: return token.expired_at < timezone.now() + + +@admin.register(UserPasskeyCredential) +class UserPasskeyCredentialAdmin(admin.ModelAdmin): + list_display = [ + "id", + "user", + "name", + "device_type", + "backup_state", + "is_enabled", + "last_used_at", + "created_at", + ] + list_filter = ["is_enabled", "backup_state", "device_type"] + search_fields = ["user__username", "credential_id", "name"] + ordering = ["-id"] diff --git a/apps/account/constants.py b/apps/account/constants.py index f5b00d4..a02fc5f 100644 --- a/apps/account/constants.py +++ b/apps/account/constants.py @@ -12,6 +12,10 @@ WECHAT_LOGIN_STATE_KEY = "wechat_login_state:{state}" WECHAT_USER_INFO_KEY = "wechat_user_info:{code}" +PASSKEY_AUTH_CHALLENGE_KEY = "passkey_auth_challenge:{challenge_id}" +PASSKEY_REGISTER_CHALLENGE_KEY = "passkey_register_challenge:{challenge_id}" +PASSKEY_BIND_KEY = "passkey_bind:{code}" + class UserTypeChoices(TextChoices): """ diff --git a/apps/account/exceptions.py b/apps/account/exceptions.py index 3cafd0f..92e150a 100644 --- a/apps/account/exceptions.py +++ b/apps/account/exceptions.py @@ -51,3 +51,28 @@ class PhoneVerifyCodeInvalid(APIException): class RegistryLocked(APIException): status_code = status.HTTP_503_SERVICE_UNAVAILABLE default_detail = gettext_lazy("Registry Disabled") + + +class PasskeyAuthenticationFailed(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = gettext_lazy("Passkey Authentication Failed") + + +class PasskeyRegistrationFailed(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = gettext_lazy("Passkey Registration Failed") + + +class PasskeyCredentialNotFound(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = gettext_lazy("Passkey Credential Not Found") + + +class PasskeyCodeInvalid(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = gettext_lazy("Passkey Code Invalid") + + +class PasskeyCredentialExists(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = gettext_lazy("Passkey Credential Already Exists") diff --git a/apps/account/migrations/0009_userpasskeycredential.py b/apps/account/migrations/0009_userpasskeycredential.py new file mode 100644 index 0000000..4a0ae01 --- /dev/null +++ b/apps/account/migrations/0009_userpasskeycredential.py @@ -0,0 +1,53 @@ +# pylint: disable=C0103 +# Generated by Django 5.2.13 on 2026-06-26 06:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0008_rename_usertoken_user_session_key_expired_at_account_use_user_id_c02769_idx_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="UserPasskeyCredential", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID")), + ( + "credential_id", + models.CharField(db_index=True, max_length=512, unique=True, verbose_name="Credential ID"), + ), + ("public_key", models.BinaryField(verbose_name="Public Key")), + ("sign_count", models.PositiveBigIntegerField(default=0, verbose_name="Sign Count")), + ("user_handle", models.CharField(db_index=True, max_length=255, verbose_name="User Handle")), + ("transports", models.JSONField(blank=True, default=list, verbose_name="Transports")), + ("device_type", models.CharField(blank=True, max_length=64, null=True, verbose_name="Device Type")), + ("backup_state", models.BooleanField(default=False, verbose_name="Backup State")), + ("name", models.CharField(blank=True, max_length=128, null=True, verbose_name="Name")), + ("last_used_at", models.DateTimeField(blank=True, null=True, verbose_name="Last Used at")), + ("is_enabled", models.BooleanField(db_index=True, default=True, verbose_name="Is Enabled")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created at")), + ( + "user", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "User Passkey Credential", + "verbose_name_plural": "User Passkey Credential", + "ordering": ["-id"], + "indexes": [ + models.Index(fields=["user", "is_enabled"], name="account_use_user_id_bce0b4_idx"), + models.Index(fields=["user", "created_at"], name="account_use_user_id_40c1a8_idx"), + ], + }, + ), + ] diff --git a/apps/account/models.py b/apps/account/models.py index 984b74a..52390dd 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -217,3 +217,34 @@ class Meta: models.Index(fields=["user", "expired_at"]), models.Index(fields=["user", "session_key", "expired_at"]), ] + + +class UserPasskeyCredential(models.Model): + """ + User Passkey Credential + """ + + id = models.BigAutoField(gettext_lazy("ID"), primary_key=True) + user = models.ForeignKey( + verbose_name=gettext_lazy("User"), to="User", on_delete=models.PROTECT, db_constraint=False + ) + credential_id = models.CharField(gettext_lazy("Credential ID"), max_length=512, unique=True, db_index=True) + public_key = models.BinaryField(gettext_lazy("Public Key")) + sign_count = models.PositiveBigIntegerField(gettext_lazy("Sign Count"), default=0) + user_handle = models.CharField(gettext_lazy("User Handle"), max_length=255, db_index=True) + transports = models.JSONField(gettext_lazy("Transports"), default=list, blank=True) + device_type = models.CharField(gettext_lazy("Device Type"), max_length=64, blank=True, null=True) + backup_state = models.BooleanField(gettext_lazy("Backup State"), default=False) + name = models.CharField(gettext_lazy("Name"), max_length=128, blank=True, null=True) + last_used_at = models.DateTimeField(gettext_lazy("Last Used at"), null=True, blank=True) + is_enabled = models.BooleanField(gettext_lazy("Is Enabled"), default=True, db_index=True) + created_at = models.DateTimeField(gettext_lazy("Created at"), auto_now_add=True) + + class Meta: + verbose_name = gettext_lazy("User Passkey Credential") + verbose_name_plural = verbose_name + ordering = ["-id"] + indexes = [ + models.Index(fields=["user", "is_enabled"]), + models.Index(fields=["user", "created_at"]), + ] diff --git a/apps/account/passkeys.py b/apps/account/passkeys.py new file mode 100644 index 0000000..c00a131 --- /dev/null +++ b/apps/account/passkeys.py @@ -0,0 +1,218 @@ +import base64 +import json +import secrets +from dataclasses import dataclass + +from django.conf import settings +from django.core.cache import cache +from django.db import IntegrityError +from django.utils import timezone +from webauthn import ( + generate_authentication_options, + generate_registration_options, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers import options_to_json +from webauthn.helpers.exceptions import WebAuthnException +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from apps.account.constants import ( + PASSKEY_AUTH_CHALLENGE_KEY, + PASSKEY_BIND_KEY, + PASSKEY_REGISTER_CHALLENGE_KEY, +) +from apps.account.exceptions import ( + PasskeyAuthenticationFailed, + PasskeyCodeInvalid, + PasskeyCredentialExists, + PasskeyCredentialNotFound, + PasskeyRegistrationFailed, +) +from apps.account.models import User, UserPasskeyCredential + + +@dataclass +class PasskeyOptions: + challenge_id: str + public_key: dict + + +def b64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode().rstrip("=") + + +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def generate_passkey_login_options(session_key: str) -> PasskeyOptions: + options = generate_authentication_options( + rp_id=settings.PASSKEY_RP_ID, + timeout=settings.PASSKEY_CHALLENGE_TIMEOUT * 1000, + user_verification=UserVerificationRequirement.REQUIRED, + ) + challenge_id = secrets.token_urlsafe(32) + cache.set( + PASSKEY_AUTH_CHALLENGE_KEY.format(challenge_id=challenge_id), + {"challenge": b64url_encode(options.challenge), "session_key": session_key}, + timeout=settings.PASSKEY_CHALLENGE_TIMEOUT, + ) + public_key = json.loads(options_to_json(options)) + if not public_key.get("allowCredentials"): + public_key.pop("allowCredentials", None) + return PasskeyOptions(challenge_id=challenge_id, public_key=public_key) + + +def generate_passkey_register_options(session_key: str, display_name: str = "") -> PasskeyOptions: + user_handle = secrets.token_bytes(32) + user_name = display_name or "Pending Passkey User" + options = generate_registration_options( + rp_id=settings.PASSKEY_RP_ID, + rp_name=settings.PASSKEY_RP_NAME, + user_name=user_name, + user_id=user_handle, + user_display_name=user_name, + timeout=settings.PASSKEY_CHALLENGE_TIMEOUT * 1000, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.REQUIRED, + require_resident_key=True, + user_verification=UserVerificationRequirement.REQUIRED, + ), + ) + challenge_id = secrets.token_urlsafe(32) + cache.set( + PASSKEY_REGISTER_CHALLENGE_KEY.format(challenge_id=challenge_id), + { + "challenge": b64url_encode(options.challenge), + "session_key": session_key, + "user_handle": b64url_encode(user_handle), + }, + timeout=settings.PASSKEY_CHALLENGE_TIMEOUT, + ) + return PasskeyOptions(challenge_id=challenge_id, public_key=json.loads(options_to_json(options))) + + +def create_pending_passkey(challenge_id: str, credential: dict, session_key: str, name: str = "") -> str: + cache_key = PASSKEY_REGISTER_CHALLENGE_KEY.format(challenge_id=challenge_id) + challenge_data = cache.get(cache_key) + cache.delete(cache_key) + if not challenge_data or challenge_data.get("session_key") != session_key: + raise PasskeyRegistrationFailed() + + try: + verified = verify_registration_response( + credential=credential, + expected_challenge=b64url_decode(challenge_data["challenge"]), + expected_rp_id=settings.PASSKEY_RP_ID, + expected_origin=settings.PASSKEY_ALLOWED_ORIGINS, + require_user_verification=True, + ) + except (KeyError, TypeError, ValueError, WebAuthnException) as err: + raise PasskeyRegistrationFailed() from err + + credential_id = b64url_encode(verified.credential_id) + # pylint: disable=E1101 + if UserPasskeyCredential.objects.filter(credential_id=credential_id).exists(): + raise PasskeyCredentialExists() + + code = secrets.token_urlsafe(32) + cache.set( + PASSKEY_BIND_KEY.format(code=code), + { + "credential_id": credential_id, + "public_key": b64url_encode(verified.credential_public_key), + "session_key": session_key, + "sign_count": verified.sign_count, + "user_handle": challenge_data["user_handle"], + "transports": _load_transports(credential), + "device_type": getattr(verified.credential_device_type, "value", str(verified.credential_device_type)), + "backup_state": verified.credential_backed_up, + "name": name or "", + }, + timeout=settings.PASSKEY_BIND_TIMEOUT, + ) + return code + + +def bind_pending_passkey(user: User, code: str, session_key: str) -> UserPasskeyCredential: + cache_key = PASSKEY_BIND_KEY.format(code=code) + pending = cache.get(cache_key) + cache.delete(cache_key) + if not pending or pending.get("session_key") != session_key: + raise PasskeyCodeInvalid() + + try: + # pylint: disable=E1101 + return UserPasskeyCredential.objects.create( + user=user, + credential_id=pending["credential_id"], + public_key=b64url_decode(pending["public_key"]), + sign_count=pending["sign_count"], + user_handle=pending["user_handle"], + transports=pending["transports"], + device_type=pending["device_type"], + backup_state=pending["backup_state"], + name=pending["name"] or None, + ) + except IntegrityError as err: + raise PasskeyCredentialExists() from err + + +def verify_passkey_login(challenge_id: str, credential: dict, session_key: str) -> User: + cache_key = PASSKEY_AUTH_CHALLENGE_KEY.format(challenge_id=challenge_id) + challenge_data = cache.get(cache_key) + cache.delete(cache_key) + if not challenge_data or challenge_data.get("session_key") != session_key: + raise PasskeyAuthenticationFailed() + + credential_id = _load_credential_id(credential) + # pylint: disable=E1101 + passkey = ( + UserPasskeyCredential.objects.filter(credential_id=credential_id, is_enabled=True) + .select_related("user") + .first() + ) + if not passkey: + raise PasskeyCredentialNotFound() + + try: + verified = verify_authentication_response( + credential=credential, + expected_challenge=b64url_decode(challenge_data["challenge"]), + expected_rp_id=settings.PASSKEY_RP_ID, + expected_origin=settings.PASSKEY_ALLOWED_ORIGINS, + credential_public_key=bytes(passkey.public_key), + credential_current_sign_count=passkey.sign_count, + require_user_verification=True, + ) + except (KeyError, TypeError, ValueError, WebAuthnException) as err: + raise PasskeyAuthenticationFailed() from err + + if b64url_encode(verified.credential_id) != passkey.credential_id or not verified.user_verified: + raise PasskeyAuthenticationFailed() + + passkey.sign_count = verified.new_sign_count + passkey.device_type = getattr(verified.credential_device_type, "value", str(verified.credential_device_type)) + passkey.backup_state = verified.credential_backed_up + passkey.last_used_at = timezone.now() + passkey.save(update_fields=["sign_count", "device_type", "backup_state", "last_used_at"]) + return passkey.user + + +def _load_credential_id(credential: dict) -> str: + credential_id = credential.get("id") or credential.get("rawId") + if not credential_id: + raise PasskeyAuthenticationFailed() + return credential_id + + +def _load_transports(credential: dict) -> list[str]: + transports = credential.get("response", {}).get("transports") or [] + if not isinstance(transports, list): + return [] + return [str(transport) for transport in transports] diff --git a/apps/account/serializers.py b/apps/account/serializers.py index b64f381..212800c 100644 --- a/apps/account/serializers.py +++ b/apps/account/serializers.py @@ -38,6 +38,7 @@ class SignInSerializer(serializers.Serializer): password = serializers.CharField(label=gettext_lazy("Password")) is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False) wechat_code = serializers.CharField(label=gettext_lazy("WeChat Code"), required=False) + passkey_code = serializers.CharField(label=gettext_lazy("Passkey Code"), required=False) tcaptcha = serializers.JSONField(label=gettext_lazy("Tencent Captcha"), required=False, default=dict) def validate(self, attrs: dict) -> dict: @@ -55,6 +56,7 @@ class UserRegistrySerializer(serializers.ModelSerializer): username = serializers.RegexField(label=gettext_lazy("Username"), regex=USERNAME_REGEX) is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False) wechat_code = serializers.CharField(label=gettext_lazy("WeChat Code"), required=False) + passkey_code = serializers.CharField(label=gettext_lazy("Passkey Code"), required=False) phone_area = serializers.ChoiceField(label=gettext_lazy("Phone Area"), choices=PhoneNumberAreas.choices) phone_number = serializers.CharField(label=gettext_lazy("Phone Number")) phone_verify = serializers.CharField( @@ -72,6 +74,7 @@ class Meta: "password", "is_oauth", "wechat_code", + "passkey_code", "phone_area", "phone_number", "phone_verify", @@ -120,6 +123,44 @@ class WeChatLoginReqSerializer(serializers.Serializer): is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False) +class PasskeyLoginOptionsReqSerializer(serializers.Serializer): + """ + Passkey Login Options + """ + + is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False) + + +class PasskeyLoginReqSerializer(serializers.Serializer): + """ + Passkey Login + """ + + challenge_id = serializers.CharField(label=gettext_lazy("Challenge ID")) + credential = serializers.JSONField(label=gettext_lazy("Credential")) + is_oauth = serializers.BooleanField(label=gettext_lazy("Is OAuth"), default=False) + + +class PasskeyRegisterOptionsReqSerializer(serializers.Serializer): + """ + Passkey Register Options + """ + + display_name = serializers.CharField( + label=gettext_lazy("Display Name"), required=False, allow_blank=True, max_length=128 + ) + + +class PasskeyRegisterPendingReqSerializer(serializers.Serializer): + """ + Passkey Register Pending + """ + + challenge_id = serializers.CharField(label=gettext_lazy("Challenge ID")) + credential = serializers.JSONField(label=gettext_lazy("Credential")) + name = serializers.CharField(label=gettext_lazy("Name"), required=False, allow_blank=True, max_length=128) + + class ResetPasswordRequestSerializer(serializers.Serializer): """ Reset Password diff --git a/apps/account/views.py b/apps/account/views.py index 612f38d..38be47b 100644 --- a/apps/account/views.py +++ b/apps/account/views.py @@ -31,9 +31,20 @@ WrongToken, ) from apps.account.models import User +from apps.account.passkeys import ( + bind_pending_passkey, + create_pending_passkey, + generate_passkey_login_options, + generate_passkey_register_options, + verify_passkey_login, +) from apps.account.rates import IPRateThrottle, SMSRateThrottle from apps.account.serializers import ( OIDCLoginRequestSerializer, + PasskeyLoginOptionsReqSerializer, + PasskeyLoginReqSerializer, + PasskeyRegisterOptionsReqSerializer, + PasskeyRegisterPendingReqSerializer, ResetPasswordRequestSerializer, SendVerifyCodeRequestSerializer, SignInSerializer, @@ -93,6 +104,12 @@ def sign_in(self, request, *args, **kwargs): # bind wechat if request_data.get("wechat_code"): self.update_user_by_wechat(user, request_data["wechat_code"]) + if request_data.get("passkey_code"): + bind_pending_passkey( + user=user, + code=request_data["passkey_code"], + session_key=self.get_passkey_session_key(request), + ) # auth session auth.login(request, user) @@ -141,6 +158,12 @@ def sign_up(self, request, *args, **kwargs): # bind wechat if request_data.get("wechat_code"): self.update_user_by_wechat(user, request_data["wechat_code"]) + if request_data.get("passkey_code"): + bind_pending_passkey( + user=user, + code=request_data["passkey_code"], + session_key=self.get_passkey_session_key(request), + ) # login session auth.login(request, user) @@ -294,6 +317,93 @@ def wechat_login(self, request, *args, **kwargs): # need registry return Response({"wechat_code": code}) + @action( + methods=["POST"], + detail=False, + authentication_classes=[SessionAuthenticate], + throttle_classes=[IPRateThrottle], + ) + def passkey_login_options(self, request, *args, **kwargs): + """ + Passkey Login Options + """ + + request_serializer = PasskeyLoginOptionsReqSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + options = generate_passkey_login_options(session_key=self.get_passkey_session_key(request)) + return Response({"challenge_id": options.challenge_id, "publicKey": options.public_key}) + + @action( + methods=["POST"], + detail=False, + authentication_classes=[SessionAuthenticate], + throttle_classes=[IPRateThrottle], + ) + def passkey_login(self, request, *args, **kwargs): + """ + Passkey Login + """ + + request_serializer = PasskeyLoginReqSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + request_data = request_serializer.validated_data + + user = verify_passkey_login( + challenge_id=request_data["challenge_id"], + credential=request_data["credential"], + session_key=self.get_passkey_session_key(request), + ) + + auth.login(request, user) + return Response({"code": user.generate_oauth_code() if request_data["is_oauth"] else ""}) + + @action( + methods=["POST"], + detail=False, + authentication_classes=[SessionAuthenticate], + throttle_classes=[IPRateThrottle], + ) + def passkey_register_options(self, request, *args, **kwargs): + """ + Passkey Register Options + """ + + request_serializer = PasskeyRegisterOptionsReqSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + request_data = request_serializer.validated_data + options = generate_passkey_register_options( + session_key=self.get_passkey_session_key(request), display_name=request_data.get("display_name", "") + ) + return Response({"challenge_id": options.challenge_id, "publicKey": options.public_key}) + + @action( + methods=["POST"], + detail=False, + authentication_classes=[SessionAuthenticate], + throttle_classes=[IPRateThrottle], + ) + def passkey_register_pending(self, request, *args, **kwargs): + """ + Passkey Register Pending + """ + + request_serializer = PasskeyRegisterPendingReqSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + request_data = request_serializer.validated_data + code = create_pending_passkey( + challenge_id=request_data["challenge_id"], + credential=request_data["credential"], + session_key=self.get_passkey_session_key(request), + name=request_data.get("name", ""), + ) + return Response({"passkey_code": code}) + + @staticmethod + def get_passkey_session_key(request) -> str: + if not request.session.session_key: + request.session.save() + return request.session.session_key + def update_user_by_wechat(self, user: User, wechat_code: str) -> None: """ Update User Info By WeChat diff --git a/entry/settings.py b/entry/settings.py index 9456aa7..db6d025 100644 --- a/entry/settings.py +++ b/entry/settings.py @@ -1,5 +1,6 @@ import os from pathlib import Path +from urllib.parse import urlparse import pymysql from environ.environ import Env @@ -251,6 +252,15 @@ WECHAT_OAUTH_API = os.getenv("WECHAT_OAUTH_API", "https://api.weixin.qq.com/sns/oauth2") WECHAT_USER_INFO_API = os.getenv("WECHAT_OAUTH_API", "https://api.weixin.qq.com/sns/userinfo") +# Passkey / WebAuthn +PASSKEY_RP_ID = os.getenv("PASSKEY_RP_ID", urlparse(FRONTEND_URL).hostname or "") +PASSKEY_RP_NAME = os.getenv("PASSKEY_RP_NAME", APP_CODE) +PASSKEY_ALLOWED_ORIGINS = [ + origin.strip() for origin in os.getenv("PASSKEY_ALLOWED_ORIGINS", FRONTEND_URL).split(",") if origin.strip() +] +PASSKEY_CHALLENGE_TIMEOUT = int(os.getenv("PASSKEY_CHALLENGE_TIMEOUT", str(60 * 5))) +PASSKEY_BIND_TIMEOUT = int(os.getenv("PASSKEY_BIND_TIMEOUT", str(60 * 30))) + # Captcha CAPTCHA_TCLOUD_ID = os.getenv("CAPTCHA_TCLOUD_ID", QCLOUD_SECRET_ID) CAPTCHA_TCLOUD_KEY = os.getenv("CAPTCHA_TCLOUD_KEY", QCLOUD_SECRET_KEY) diff --git a/env.example b/env.example index 8bc01f6..a99b957 100644 --- a/env.example +++ b/env.example @@ -4,6 +4,9 @@ BACKEND_URL=https://dev-api.example.com ALLOWED_HOSTS=dev-api.example.com FRONTEND_URL=https://dev.example.com CORS_ORIGIN_WHITELIST=https://dev.example.com +PASSKEY_RP_ID=dev.example.com +PASSKEY_RP_NAME=union-api +PASSKEY_ALLOWED_ORIGINS=https://dev.example.com DB_NAME=union-api DB_USER=union-api DB_PASSWORD= diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po index 95494c9..2a8c10d 100644 --- a/locale/zh_Hans/LC_MESSAGES/django.po +++ b/locale/zh_Hans/LC_MESSAGES/django.po @@ -3,12 +3,11 @@ # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-05 20:00+0800\n" +"POT-Creation-Date: 2026-06-26 14:59+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -81,6 +80,21 @@ msgstr "电话验证码错误" msgid "Registry Disabled" msgstr "未开放注册" +msgid "Passkey Authentication Failed" +msgstr "Passkey 认证失败" + +msgid "Passkey Registration Failed" +msgstr "Passkey 注册失败" + +msgid "Passkey Credential Not Found" +msgstr "Passkey 凭据不存在" + +msgid "Passkey Code Invalid" +msgstr "Passkey Code 无效" + +msgid "Passkey Credential Already Exists" +msgstr "Passkey 凭据已存在" + msgid "Username Cannot be Empty" msgstr "用户名不能为空" @@ -130,12 +144,10 @@ msgid "Cache Key" msgstr "缓存键" msgid "Login IP" -msgstr "" +msgstr "登录 IP" -#, fuzzy -#| msgid "User Account" msgid "User Agent" -msgstr "用户账户" +msgstr "User-Agent" msgid "Expired at" msgstr "过期时间" @@ -143,6 +155,42 @@ msgstr "过期时间" msgid "User Token" msgstr "用户令牌" +msgid "Credential ID" +msgstr "凭据 ID" + +msgid "Public Key" +msgstr "公钥" + +msgid "Sign Count" +msgstr "签名计数" + +msgid "User Handle" +msgstr "用户句柄" + +msgid "Transports" +msgstr "传输方式" + +msgid "Device Type" +msgstr "设备类型" + +msgid "Backup State" +msgstr "备份状态" + +msgid "Name" +msgstr "名称" + +msgid "Last Used at" +msgstr "最后使用时间" + +msgid "Is Enabled" +msgstr "是否启用" + +msgid "Created at" +msgstr "创建时间" + +msgid "User Passkey Credential" +msgstr "用户 Passkey 凭据" + msgid "Username" msgstr "用户名" @@ -155,6 +203,9 @@ msgstr "是否 OAuth" msgid "WeChat Code" msgstr "微信 Code" +msgid "Passkey Code" +msgstr "Passkey Code" + msgid "Tencent Captcha" msgstr "腾讯云验证码" @@ -173,6 +224,15 @@ msgstr "电话已被使用" msgid "State" msgstr "状态" +msgid "Challenge ID" +msgstr "Challenge ID" + +msgid "Credential" +msgstr "凭据" + +msgid "Display Name" +msgstr "显示名称" + msgid "Redirect To" msgstr "重定向地址" @@ -266,9 +326,6 @@ msgstr "通知记录" msgid "Webhook" msgstr "回调地址" -msgid "Name" -msgstr "名称" - msgid "Robot" msgstr "机器人" diff --git a/requirements.txt b/requirements.txt index 43f8b76..a24eb28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,9 @@ arrow==1.4.0 # RSA pycryptodome==3.23.0 +# Passkey / WebAuthn +webauthn>=2.6,<3 + # tencent cloud tencentcloud-sdk-python==3.1.2 From edfaa3ddbc0b2af410b185e25fe466e57bd85d5b Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:18:46 +0800 Subject: [PATCH 2/2] feat(account): disable password login when passkey or WeChat binding exists --- apps/account/exceptions.py | 5 +++++ apps/account/views.py | 21 ++++++++++++++++++++- locale/zh_Hans/LC_MESSAGES/django.po | 5 ++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/account/exceptions.py b/apps/account/exceptions.py index 92e150a..3d3dc4e 100644 --- a/apps/account/exceptions.py +++ b/apps/account/exceptions.py @@ -18,6 +18,11 @@ class WrongSignInParam(APIException): default_detail = gettext_lazy("Wrong Username or Password") +class PasswordLoginDisabled(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = gettext_lazy("Password Login Disabled") + + class TokenNotExist(APIException): status_code = status.HTTP_400_BAD_REQUEST default_detail = gettext_lazy("Token Not Exist") diff --git a/apps/account/views.py b/apps/account/views.py index 38be47b..a892a54 100644 --- a/apps/account/views.py +++ b/apps/account/views.py @@ -23,6 +23,7 @@ WeChatAuthType, ) from apps.account.exceptions import ( + PasswordLoginDisabled, RegistryLocked, StateInvalid, UserNotExist, @@ -30,7 +31,7 @@ WrongSignInParam, WrongToken, ) -from apps.account.models import User +from apps.account.models import User, UserPasskeyCredential from apps.account.passkeys import ( bind_pending_passkey, create_pending_passkey, @@ -104,6 +105,8 @@ def sign_in(self, request, *args, **kwargs): # bind wechat if request_data.get("wechat_code"): self.update_user_by_wechat(user, request_data["wechat_code"]) + + # bind passkey if request_data.get("passkey_code"): bind_pending_passkey( user=user, @@ -111,6 +114,10 @@ def sign_in(self, request, *args, **kwargs): session_key=self.get_passkey_session_key(request), ) + # check allow password login + if self.is_password_login_disabled(user, request_data): + raise PasswordLoginDisabled() + # auth session auth.login(request, user) @@ -404,6 +411,18 @@ def get_passkey_session_key(request) -> str: request.session.save() return request.session.session_key + @classmethod + def is_password_login_disabled(cls, user: User, request_data: dict) -> bool: + # bind request + if request_data.get("wechat_code") or request_data.get("passkey_code"): + return False + # wechat bind + if user.wechat_union_id or user.wechat_open_id: + return True + # passkey bind + # pylint: disable=E1101 + return UserPasskeyCredential.objects.filter(user=user, is_enabled=True).exists() + def update_user_by_wechat(self, user: User, wechat_code: str) -> None: """ Update User Info By WeChat diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po index 2a8c10d..4ae1ca1 100644 --- a/locale/zh_Hans/LC_MESSAGES/django.po +++ b/locale/zh_Hans/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-26 14:59+0800\n" +"POT-Creation-Date: 2026-06-26 16:17+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -59,6 +59,9 @@ msgstr "用户不存在" msgid "Wrong Username or Password" msgstr "用户名或密码错误" +msgid "Password Login Disabled" +msgstr "用户名密码登录已被禁用" + msgid "Token Not Exist" msgstr "Token 不存在"