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以后会成为主流的。
引用
Shein, Esther. WWDC 2022: Apple announces Passkey feature | ZDNET. TechRepublic (TechnologyAdvice). 2022-06-06. ↩︎