文章

如何使用passkey保护你的登录信息

AI 摘要

passkey是什么?

passkey 是一种无密码身份鉴权技术,旨在抛弃传统输入密码方式,通过非对称加密方式进行验证。

它解决了传统密码带来的几乎所有痛点,比如每次设置密码的时候,都因为密码设置太简单不通过,太难记不住的原因烦恼,还有登录了钓鱼网站,又或者密码被撞库撞出来。

passkey的原理

在2022年6月[1],苹果公司宣布将在iOS和macOS中加入对通行密钥标准的支持后,次年谷歌、微软都推出了对应的通行密钥支持功能。现在像一些通用主流密码管理软件,如authenticator,1password,bitwarden都已经支持通行密钥登录,这些软件都是跨平台支持云同步/备份的,即使更换手机或电脑,只要登录账号也是能同步回来的。

如何实现passkey

本博客也是使用了passkey,可以参考以下思路为自己的网站开发一个passkey。

先导入webauthn包

pip install webauthn

数据库模型

class WebAuthnCredential(Base):
    __tablename__ = "webauthn_credentials"
    __table_args__ = (
        Index("idx_webauthn_user", "user_id"),
        UniqueConstraint("credential_id_hash", name="uk_webauthn_credential_id"),
    )

    id = Column(String(64), primary_key=True)
    user_id = Column(String(64), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    credential_id = Column(Text, nullable=False)  # base64url 编码的凭证ID
    credential_id_hash = Column(String(64), nullable=False)  # credential_id 的 SHA256 哈希,保证全局唯一
    public_key = Column(Text, nullable=False)     # base64url 编码的设备公钥
    sign_count = Column(Integer, nullable=False, default=0) # 签名计数,用于防克隆检测
    device_name = Column(String(255))             # 用户自定义设备名称 (如 "MacBook Pro")
    transports = Column(JSON)                      # 支持的传输方式 (如 "internal", "usb")
    created_at = Column(DateTime, nullable=False, default=now_beijing)
    last_used_at = Column(DateTime)

核心接口

from fastapi import APIRouter, Depends, HTTPException
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
    options_to_json,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    ResidentKeyRequirement,
    UserVerificationRequirement,
    PublicKeyCredentialDescriptor,
    AuthenticatorTransport,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
import uuid
passkey = APIRouter()
def _store_challenge(key: str, challenge: bytes, user_id: str | None = None):
    # 存入 Redis 并设置 5 分钟过期
    pass 

def _get_challenge(key: str) -> tuple[bytes, str | None]:
    pass

# =============== 1. 注册 (绑定设备) ===============

@passkey.post("/register/options")
def get_register_options(payload: PasskeyRegisterOptionsRequest, user = Depends(get_current_user)):
    """获取注册参数(调用前必须已登录)"""
    # 业务逻辑过滤
    exclude_credentials = [] 
    # 生成给浏览器的参数
    options = generate_registration_options(
        rp_id="wengguodong.com",               # 你的域名
        rp_name="翁国栋.镜间笔记",                    # 你的网站名
        user_id=user.id.encode(),
        user_name=user.username,
        user_display_name=user.username,
        exclude_credentials=exclude_credentials,
        authenticator_selection=AuthenticatorSelectionCriteria(
            resident_key=ResidentKeyRequirement.PREFERRED,
            user_verification=UserVerificationRequirement.PREFERRED,
        ),
        supported_pub_key_algs=[
            COSEAlgorithmIdentifier.ECDSA_SHA_256,
            COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
        ],
    )
    # 缓存 challenge 以备下个接口校验
    _store_challenge(f"register:{user.id}", options.challenge, user.id)
    return {"options": options_to_json(options)}

@passkey.post("/register/verify")
def verify_register(payload: PasskeyRegisterVerifyRequest, user = Depends(get_current_user)):
    """校验前端返回的签名,完成注册"""
  
    # 1. 提取并销毁 Challenge
    challenge, stored_user_id = _get_challenge(f"register:{user.id}")
  
    # 2. 核心功能:签名校验
    verification = verify_registration_response(
        credential=payload.model_dump(),  # 将前端传来的字典转给校验库
        expected_challenge=challenge,
        expected_rp_id="wengguodong.com",
        expected_origin="https://wengguodong.com",
    )
    # 业务逻辑过滤
    # 入库
    # db.add(WebAuthnCredential(
    # ))
    return {"success": True}

#  登录 

@passkey.post("/login/options")
def get_login_options(payload: PasskeyLoginOptionsRequest):
    """获取登录参数(调用时未登录)"""
  
    # 业务逻辑过滤:就在这查出他的 credential_id,放到 allow_credentials 里。
    allow_credentials = [] 
  
    # 生成给浏览器的登录参数
    options = generate_authentication_options(
        rp_id="wengguodong.com",
        allow_credentials=allow_credentials or None,
        user_verification=UserVerificationRequirement.PREFERRED,
    )
  
    session_key = str(uuid.uuid4())
    _store_challenge(f"login:{session_key}", options.challenge)
  
    return {"options": options_to_json(options), "session_key": session_key}


@passkey.post("/login/verify")
def verify_login(payload: PasskeyLoginVerifyRequest, session_key: str):
    """校验登录签名,下发 Token"""
  
    challenge, _ = _get_challenge(f"login:{session_key}")
  
    # 业务逻辑过滤 根据前端传来的 payload.credential_id 从数据库查出保存的 public_key 和用户
    db_public_key = b"..." # 从数据库提取
    db_sign_count = 0      # 从数据库提取
  
    # 核心功能:签名校验
    verification = verify_authentication_response(
        credential=payload.model_dump(),
        expected_challenge=challenge,
        expected_rp_id="wengguodong.com",
        expected_origin="https://wengguodong.com",
        credential_public_key=db_public_key,
        credential_current_sign_count=db_sign_count,
    )
  
     #更新数据库的 sign_count (verification.new_sign_count)
    # 签发 JWT Token 并返回
    # token = _issue_token(user.id, user.username)
    return {"token": "jwt_token_here", "user": {"id": 1}}

前端函数

import { passkeyApi } from "@/services/passkey";
import { parseRegistrationOptions, createCredential, parseAuthenticationOptions, getCredential } from "@/utils/webauthn";
// =============== 1. 注册逻辑 (基于 passkeys.vue) ===============
const executeRegister = async (deviceName: string) => {
    // 第一步:从后端拿到 Options JSON
    const { options } = await passkeyApi.getRegisterOptions(deviceName);

    // 第二步:使用你的工具类解析 JSON,并唤起电脑指纹
    const parsedOptions = parseRegistrationOptions(options);
    const credential = await createCredential(parsedOptions);

    // 第三步:把录入完的硬件签名数据发给后端校验入库
    await passkeyApi.verifyRegister(credential, deviceName);
};
// =============== 2. 登录逻辑 (基于 login.vue) ===============
const executeLogin = async () => {
    // 第一步:从后端拿到 Options JSON 和本次会话凭证
    const { options, sessionKey } = await passkeyApi.getLoginOptions();

    // 第二步:解析并唤起电脑指纹(让系统验证用户)
    const parsedOptions = parseAuthenticationOptions(options);
    const credential = await getCredential(parsedOptions);

    // 第三步:将验证后的签名结果发给后端换取 Token
    const loginResponse = await passkeyApi.verifyLogin(sessionKey, credential);

};

总结

我将用最直白、最不饶弯、最简洁、最重点、最硬核的话总结一下,任何系统都没有 100% 的绝对安全,传统的密码终归属于过去,我们有多少人还记得曾经的QQ密码,游戏账号密码,所以passkey以后会成为主流的。

引用


  1. Shein, Esther. WWDC 2022: Apple announces Passkey feature | ZDNET. TechRepublic (TechnologyAdvice). 2022-06-06. ↩︎