实战!为你的网站接入 Passkey 通行密钥以实现无密码安全登录
前言
说来也巧,最近在研究 Passkey,本来思前想后是不写这篇文章的(因为懒),但是昨天刷知乎的时候发现廖雪峰廖老师也在研究 Passkey,想了想还是写一篇蹭蹭热度吧。
了解 Passkey
要了解 Passkey,我们首先需要了解 Web Authentication credential(Web 认证凭据)
,简而言之,Web 认证凭据是一种使用非对称加密代替密码或 SMS 短信在网站上注册、登录、双因素验证的方式。通过操作系统-用户代理(浏览器)-服务器三方的交互,我们得以以无密码的方式完成对指定服务的身份鉴权。Web 认证凭据目前被广泛使用在双因素认证(2FA)中。
Passkey 则是一种特殊的 Web 认证凭据:与传统的 Web 认证凭据不同, Passkey 可用于同时识别和验证用户,而前者只能用于验证用户信息,不能用来识别用户,这得益于 Passkey 的可发现性(Discoverable)
。
通过 Passkey,我们可以通过使用操作系统的生物验证方式(例如 Windows Hello,FaceID)完成对指定站点的登录,而不必繁琐的输入账号和密码,解放用户的双手。
认识 Web Authentication API
为了创建和认证 Web 认证凭据,浏览器为我们提供了 Web Authentication API
(简称 Webauthn
),该 API 为我们提供了两个主要方法:
- navigator.credentials.create() (en-US) - 当使用 publicKey 选项时,创建一个新的凭据,无论是用于注册新账号还是将新的非对称密钥凭据与已有的账号关联。
- navigator.credentials.get() (en-US) - 当使用 publicKey 选项时,使用一组现有的凭据进行身份验证服务,无论是用于用户登录还是双因素验证中的一步。
通过这两个方法,我们可以将 Web 认证凭据的创建和认证过程大致拆分为以下几部分:
凭据创建
- 浏览器向服务器发起请求,获取凭据创建所需的 options 信息(例如站点 ID,用户信息,防重放 challenge 等);
- 浏览器调用
navigator.credentials.create()
方法,传入上一步获取的 options,浏览器调用操作系统接口弹出对话框要求用户进行身份验证以创建密钥; - 如果用户身份验证成功,那么浏览器则应该向服务器发起请求,返回上一步调用方法的返回值;服务器将对该值进行验证,如果验证通过,则将相关信息存储到数据库中,此时凭据创建成功;
凭据认证
- 浏览器向服务器发起请求,获取凭据认证所需的 options 信息(例如站点 ID,防重放 challenge 等);
- 浏览器调用
navigator.credentials.get()
方法,传入上一步获取的 options,浏览器调用操作系统接口弹出对话框要求用户选择进行身份验证的密钥并进行身份验证; - 如果用户身份验证成功,那么浏览器则应该向服务器发起请求,返回上一步调用方法的返回值;服务器将对该值进行验证,如果验证通过,则凭据认证成功,服务器可在更新密钥信息后将用户登录到站点(或者通过 2FA 验证)。
部署 Passkey 验证环境
本例中使用 Java 17 + Spring Boot 3 进行后端服务器的开发,并使用 Spring Data JPA 作为 ORM 框架(使用 PostgreSQL 作为数据库),Spring Data Redis 提供 Redis 能力支持。
除此之外,我们额外引入了三个库来简化开发:
java-webauthn-server
,这是一个基于 Scala 和 Java 开发的 Webauthn 库,提供了较为完整的 Webauthn API 对接流程;
在 Gradle 引入
java-webauthn-server
:implementation("com.yubico:webauthn-server-core:2.5.0")
在 Maven 引入
java-webauthn-server
:<dependency> <groupId>com.yubico</groupId> <artifactId>webauthn-server-core</artifactId> <version>2.5.0</version> <scope>compile</scope> </dependency>
@github/webauthn-json
,由 GitHub 开发的 Webauthn 前端辅助库,通过包装了 Webauthn API 方法以实现在服务器和浏览器之间便捷的编码并传输 options 对象数据。
通过 npm 安装
@github/webauthn-json
:npm install --save @github/webauthn-json
通过 yarn 安装
@github/webauthn-json
:yarn add --save @github/webauthn-json
通过 pnpm 安装
@github/webauthn-json
:pnpm install --save @github/webauthn-json
hypersistence-utils
,可为 Hibernate 提供更多的类型支持,此处我们使用其提供的 JSON 类型来快速的序列化java-webauthn-server
提供的 POJO。
在 Gradle 引入
hypersistence-utils
(对于 Hibernate 6.2 及以上版本):implementation("io.hypersistence:hypersistence-utils-hibernate-62:3.5.0")
在 Maven 引入
hypersistence-utils
(对于 Hibernate 6.2 及以上版本):<dependency> <groupId>io.hypersistence</groupId> <artifactId>hypersistence-utils-hibernate-62</artifactId> <version>3.5.0</version> </dependency>
实现 Passkey 创建和验证
对接 CredentialRepository
java-webauthn-server
需要访问我们存储的密钥信息才能为我们完成请求的校验工作,因此,这要求我们实现 CredentialRepository
接口:
// Copyright (c) 2018, Yubico AB
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package com.yubico.webauthn;
import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
import java.util.Optional;
import java.util.Set;
/**
* An abstraction of the database lookups needed by this library.
*
* <p>This is used by {@link RelyingParty} to look up credentials, usernames and user handles from
* usernames, user handles and credential IDs.
*/
public interface CredentialRepository {
/**
* Get the credential IDs of all credentials registered to the user with the given username.
*
* <p>After a successful registration ceremony, the {@link RegistrationResult#getKeyId()} method
* returns a value suitable for inclusion in this set.
*/
Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username);
/**
* Get the user handle corresponding to the given username - the inverse of {@link
* #getUsernameForUserHandle(ByteArray)}.
*
* <p>Used to look up the user handle based on the username, for authentication ceremonies where
* the username is already given.
*/
Optional<ByteArray> getUserHandleForUsername(String username);
/**
* Get the username corresponding to the given user handle - the inverse of {@link
* #getUserHandleForUsername(String)}.
*
* <p>Used to look up the username based on the user handle, for username-less authentication
* ceremonies.
*/
Optional<String> getUsernameForUserHandle(ByteArray userHandle);
/**
* Look up the public key and stored signature count for the given credential registered to the
* given user.
*
* <p>The returned {@link RegisteredCredential} is not expected to be long-lived. It may be read
* directly from a database or assembled from other components.
*/
Optional<RegisteredCredential> lookup(ByteArray credentialId, ByteArray userHandle);
/**
* Look up all credentials with the given credential ID, regardless of what user they're
* registered to.
*
* <p>This is used to refuse registration of duplicate credential IDs. Therefore, under normal
* circumstances this method should only return zero or one credential (this is an expected
* consequence, not an interface requirement).
*/
Set<RegisteredCredential> lookupAll(ByteArray credentialId);
}
可以看到,CredentialRepository
要求我们实现对注册凭据和用户信息的查询,为此,我们创建 WebauthnCredentialEntity
,作为数据库实体类,完成数据表结构构造:
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class WebauthnCredentialEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(nullable = false)
@Getter
@Setter
private long id;
@Column(nullable = false)
@Getter
@Setter
private long userID;
@Column(nullable = false, columnDefinition = "jsonb")
@Type(JsonType.class)
private CredentialRegistration credentialRegistration;
}
此处我们设置 credentialRegistration
字段的列类型为 jsonb
,代表 PostgreSQL 的二进制 JSON 类型,对于 MySQL,则可以使用 json
作为列类型。
该数据库实体类存储了用户 ID 和 CredentialRegistration
注册凭据的对应关系,方便我们存储用户凭据信息。
而 CredentialRegistration
数据类的构造如下:
@Builder
@Data
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.NON_PRIVATE)
public class CredentialRegistration implements Serializable {
@NotNull
UserIdentity userIdentity;
@Nullable
String credentialNickname;
@NotNull
SortedSet<@NotNull AuthenticatorTransport> transports;
@NotNull
RegisteredCredential credential;
@Nullable
Object attestationMetadata;
@NotNull
private Instant registration;
@JsonGetter("registration")
public String getRegistration() {
return registration.toString();
}
@JsonSetter("registration")
public void setRegistration(String registration) {
this.registration = Instant.parse(registration);
}
@JsonIgnore
public String getUsername() {
return userIdentity.getName();
}
}
其存储了以下关键信息:
com.yubico.webauthn.data.UserIdentity userIdentity
,存储用户标识,由String name
,String displayName
,ByteArray id
三部分组成,只有id
字段作为唯一标识符标识唯一用户,name
和displayName
则只是为用户提供人类可读的文本信息用以标识该账户的名称;String credentialNickname
,该凭据的昵称,方便用户识别,也可不填(Nullable
);SortedSet<com.yubico.webauthn.data.AuthenticatorTransport> transports
,该凭据支持的传输方式,例如USB
,BLE
,NFC
等;com.yubico.webauthn.RegisteredCredential credential
,凭证详细数据,包括凭证 ID,凭证对应的用户 ID,凭证公钥,签名计数,备份信息等。该信息由浏览器生成并发回到服务端;Object attestationMetadata
,自定义元数据, 可空(Nullable
);Instant registration
,凭据的注册时间。
根据实体类,我们创建对应的 Spring Data JPA Repository:
@Repository
public interface WebauthnCredentialRepository extends JpaRepository<WebauthnCredentialEntity, Long> {
// 根据用户 ID 获取该用户的所有凭据信息
List<WebauthnCredentialEntity> findAllByUserID(long userID);
}
然后,创建 CredentialRepositoryImpl
类,实现 CredentialRepository
接口:
@RequiredArgsConstructor
@Component
public class CredentialRepositoryImpl implements CredentialRepository {
private final WebauthnCredentialRepository webauthnCredentialRepository;
// 根据用户名获取凭证信息
@Override
public Set<PublicKeyCredentialDescriptor> getCredentialIdsForUsername(String username) {
return webauthnCredentialRepository.findAllByUserID(getUserIDByEmail(username)).stream()
.map(WebauthnCredentialEntity::getCredentialRegistration)
.map(it -> PublicKeyCredentialDescriptor.builder()
.id(it.getCredential().getCredentialId())
.transports(it.getTransports())
.build())
.collect(Collectors.toUnmodifiableSet());
}
// 根据 UserHandle 获取用户名
@Override
public Optional<String> getUsernameForUserHandle(ByteArray userHandle) {
return getRegistrationsByUserHandle(userHandle).stream()
.findAny()
.map(CredentialRegistration::getUsername);
}
// 根据用户名获取 UserHandle
@Override
public Optional<ByteArray> getUserHandleForUsername(String username) {
return getRegistrationsByUsername(username).stream()
.findAny()
.map(reg -> reg.getUserIdentity().getId());
}
// 根据凭证 ID 和 UserHandle 获取单个凭证信息
@Override
public Optional<RegisteredCredential> lookup(ByteArray credentialId, ByteArray userHandle) {
Optional<CredentialRegistration> registrationMaybe = webauthnCredentialRepository.findAll().stream()
.map(WebauthnCredentialEntity::getCredentialRegistration)
.filter(it -> it.getCredential().getCredentialId().equals(credentialId))
.findAny();
return registrationMaybe.map(it ->
RegisteredCredential.builder()
.credentialId(it.getCredential().getCredentialId())
.userHandle(it.getCredential().getUserHandle())
.publicKeyCose(it.getCredential().getPublicKeyCose())
.signatureCount(it.getCredential().getSignatureCount())
.build());
}
// 根据凭证 ID 获取多个凭证信息
@Override
public Set<RegisteredCredential> lookupAll(ByteArray credentialId) {
return webauthnCredentialRepository.findAll().stream()
.map(WebauthnCredentialEntity::getCredentialRegistration)
.filter(it -> it.getCredential().getCredentialId().equals(credentialId))
.map(it ->
RegisteredCredential.builder()
.credentialId(it.getCredential().getCredentialId())
.userHandle(it.getCredential().getUserHandle())
.publicKeyCose(it.getCredential().getPublicKeyCose())
.signatureCount(it.getCredential().getSignatureCount())
.build())
.collect(Collectors.toUnmodifiableSet());
}
private long getUserIDByEmail(String email) {
// your own implemention
}
private Collection<CredentialRegistration> getRegistrationsByUsername(String username) {
return webauthnCredentialRepository.findAllByUserID(getUserIDByEmail(username)).stream()
.map(WebauthnCredentialEntity::getCredentialRegistration)
.toList();
}
private Collection<CredentialRegistration> getRegistrationsByUserHandle(ByteArray userHandle) {
return webauthnCredentialRepository.findAll().stream()
.map(WebauthnCredentialEntity::getCredentialRegistration)
.filter(it -> it.getUserIdentity().getId().equals(userHandle))
.toList();
}
}
值得一提的是,userHandle
是一个 com.yubico.webauthn.data.ByteArray
类,封装了一个 byte[]
数组,用于代表用户的唯一 ID,而 username
并不是代表用户的用户名,而是代表某个唯一的用户标识符。在本例中,我们使用用户 ID 作为 userHandle
,而使用用户的电子邮件地址作为 username
。
最后,由于直接使用 JSON 对数据进行序列化,因此我们难以直接对某些字段进行 SQL 查询,只能全部拿出来再通过 stream
筛选,这可能会引发一些性能问题。
如此一来,我们便成功实现了 CredentialRepository
接口。
构造 RelyingParty
实现 CredentialRepository
接口后,我们便可开始构造 RelyingParty
类。在 java-webauthn-server
库中,RelyingParty
类是所有 API 操作的入口点,我们需要为其传入 id
和 name
进行构造,这对应了 Webauthn API 上 options
中的 rp
字段:
id
代表供应商 ID,应当是一段域名,该域名必须和实际服务域名完全符合(或者填入顶级域名来匹配根域名和所有二级域名);name
代表供应商名称,可随意填写。
值得一提的是,为了安全起见,浏览器上的 Webauthn API
仅会接受来自 HTTPS 连接的网站调用其 API(或者本地回环地址 localhost
,可以免于采用 HTTPS 连接);对于其他情况,该 API 会返回 undefined
。
接下来,创建 WebauthnConfiguration
类,构造 RelyingParty
类并将其注入 Spring Bean 容器中:
@RequiredArgsConstructor
@Configuration
public class WebauthnConfiguration {
private final CredentialRepository credentialRepository;
@Value("${webauthn.relying-party.id}")
private String relyingPartyId;
@Value("${webauthn.relying-party.name}")
private String relyingPartyName;
@Bean
public RelyingParty relyingParty() {
var rpIdentity = RelyingPartyIdentity.builder()
.id(relyingPartyId)
.name(relyingPartyName)
.build();
return RelyingParty.builder()
.identity(rpIdentity)
.credentialRepository(credentialRepository)
.build();
}
}
如此一来,我们便成功构造了 RelyingParty
类。
实现 Passkey 逻辑(后端 Controller,前端 hook)
接下来,让我们重新梳理一下在 认识 Web Authentication API
一节提及的凭据创建和认证流程,仔细看的话,这两个流程其实都有着共同的思路:获取 options
,调用 API,返回数据。根据这个思路,我们可以分别创建以下四个路由:
- 返回 Web 凭证创建所需的
options
:
GET /api/authorization/passkey/registration/options
- 接受前端调用凭证创建 API 后返回的凭证信息:
POST /api/authorization/passkey/registration
{
...
}
- 返回 Web 凭证认证所需的
options
:
GET /api/authorization/passkey/assertion/options
- 接受前端调用凭证校验 API 后返回的凭证信息:
POST /api/authorization/passkey/assertion
根据这个思路,在后端创建 PasskeyAuthorizationController
:
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/authorization/passkey")
@Validated
public class PasskeyAuthorizationController {
private final PasskeyAuthorizationService passkeyAuthorizationService;
@GetMapping("/registration/options")
@ResponseBody
public String getPasskeyRegistrationOptions(@RequestHeader(BizConstants.USER_ID_HEADER) long userID) {
if (userID == BizConstants.USER_ID_UNAUTHORIZED)
throw new UnauthorizedException();
return passkeyAuthorizationService.startPasskeyRegistration(userID);
}
@PostMapping("/registration")
public void verifyPasskeyRegistration(@RequestHeader(BizConstants.USER_ID_HEADER) long userID, @RequestBody String credential) {
if (userID == BizConstants.USER_ID_UNAUTHORIZED)
throw new UnauthorizedException();
passkeyAuthorizationService.finishPasskeyRegistration(userID, credential);
}
@GetMapping("/assertion/options")
@ResponseBody
public String getPasskeyAssertionOptions(HttpServletRequest httpServletRequest) {
return passkeyAuthorizationService.startPasskeyAssertion(httpServletRequest.getSession().getId());
}
@PostMapping("/assertion")
@ResponseBody
public void verifyPasskeyAssertion(HttpServletRequest httpServletRequest, @RequestBody String credential) {
var id = passkeyAuthorizationService.finishPasskeyAssertion(httpServletRequest.getSession().getId(), credential);
// Login the user with `id`
loginUser(id)
}
}
其中,PasskeyAuthorizationService
将会在下一节讲到;userID
代表用户 ID,由上游 Gateway 注入到 header 上,如果不了解的话可以看这篇文章;credential
为前端向我们提交的凭证信息;至于为什么要用到 HttpServletRequest
,也会在下一节讲到。
创建完后端的 Controller
后,让我们为前端创建一个 hook
,可以方便前端快速的进行 Passkey
的创建和认证:
import {
create,
CredentialCreationOptionsJSON,
CredentialRequestOptionsJSON,
get,
parseCreationOptionsFromJSON,
parseRequestOptionsFromJSON
} from "@github/webauthn-json/browser-ponyfill";
export default function usePasskey() {
async function isSupported(): Promise<boolean> {
// Availability of `window.PublicKeyCredential` means WebAuthn is usable.
// `isUserVerifyingPlatformAuthenticatorAvailable` means the feature detection is usable.
// `isConditionalMediationAvailable` means the feature detection is usable.
if (window.PublicKeyCredential &&
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
PublicKeyCredential.isConditionalMediationAvailable
) {
// Check if user verifying platform authenticator is available.
const results = await Promise.all([
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
PublicKeyCredential.isConditionalMediationAvailable(),
])
if (results.every(r => r === true)) {
return true;
}
}
return false
}
async function createPasskeyCredential() {
const json = await $fetch<CredentialCreationOptionsJSON>("/api/authorization/passkey/registration/options", {
method: "GET",
parseResponse: JSON.parse
})
const options = parseCreationOptionsFromJSON(json)
// replacement of navigator.credentials.create(...)
const response = await create(options);
await $fetch("/api/authorization/passkey/registration", {
method: "POST",
body: JSON.stringify(response),
})
}
async function validatePasskeyCredential() {
const json = await $fetch<CredentialRequestOptionsJSON>("/api/authorization/passkey/assertion/options", {
method: "GET",
parseResponse: JSON.parse
})
const options = parseRequestOptionsFromJSON(json)
// replacement of navigator.credentials.get(...)
const response = await get(options);
await fetch("/api/authorization/passkey/assertion", {
method: "POST",
body: JSON.stringify(response),
})
}
return {
isSupported,
createPasskeyCredential,
validatePasskeyCredential
}
}
其中,isSupported
异步方法返回一个 boolean
,代表该浏览器是否支持 Passkey 验证,createPasskeyCredential
为创建 Passkey 凭据,validatePasskeyCredential
为认证 Passkey 凭据。
值得一提的是,以上代码中 $fetch
方法来自于 ofetch
库,我们也可以使用浏览器原生的 fetch
函数乃至 XMLHttpRequest
代替。
实现 Passkey 逻辑(后端 Service)
完成了 Controller
的编写和前端的对接,接下来。让我们回到后端,看看最后的大头 —— PasskeyAuthorizationService
的实现:
@RequiredArgsConstructor
@Service
public class PasskeyAuthorizationService {
private final WebauthnCredentialRepository webauthnCredentialRepository;
private final RelyingParty relyingParty;
private final StringRedisTemplate template;
private final String REDIS_PASSKEY_REGISTRATION_KEY = "passkey:registration";
private final String REDIS_PASSKEY_ASSERTION_KEY = "passkey:assertion";
public PublicKeyCredentialCreationOptions startPasskeyRegistration(long userID) throws JsonProcessingException {
var user = getUserByUserID(userID);
var options = relyingParty.startRegistration(StartRegistrationOptions.builder()
.user(UserIdentity.builder()
.name(user.getEmail())
.displayName(user.getUsername())
.id(new ByteArray(ByteUtil.longToBytes(user.getId())))
.build())
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.residentKey(ResidentKeyRequirement.REQUIRED)
.build())
.build());
template.opsForHash().put(REDIS_PASSKEY_REGISTRATION_KEY, String.valueOf(user.getId()), options.toJson());
return options.toCredentialsCreateJson();
}
public void finishPasskeyRegistration(long userID, String credential) throws JsonProcessingException, RegistrationFailedException {
var user = getUserByUserID(userID);
var pkc = PublicKeyCredential.parseRegistrationResponseJson(credential)
var request = PublicKeyCredentialCreationOptions.fromJson((String) template.opsForHash().get(REDIS_PASSKEY_REGISTRATION_KEY, String.valueOf(user.getId())));
var result = relyingParty.finishRegistration(FinishRegistrationOptions.builder()
.request(request)
.response(pkc)
.build());
template.opsForHash().delete(REDIS_PASSKEY_REGISTRATION_KEY, String.valueOf(user.getId()));
storeCredential(user.getId(), request, result);
}
public AssertionRequest startPasskeyAssertion(String identifier) throws JsonProcessingException {
var options = relyingParty.startAssertion(StartAssertionOptions.builder().build());
template.opsForHash().put(REDIS_PASSKEY_ASSERTION_KEY, identifier, options.toJson());
return options.toCredentialsGetJson();
}
public long finishPasskeyAssertion(String identifier, String credential) throws JsonProcessingException, AssertionFailedException {
var request = AssertionRequest.fromJson((String) template.opsForHash().get(REDIS_PASSKEY_ASSERTION_KEY, identifier));
var pkc = PublicKeyCredential.parseAssertionResponseJson(credential)
var result = relyingParty.finishAssertion(FinishAssertionOptions.builder()
.request(request)
.response(pkc)
.build());
template.opsForHash().delete(REDIS_PASSKEY_ASSERTION_KEY, identifier);
if (!result.isSuccess()) {
throw new AssertionFailedException("Verify failed");
}
var user = getUserByUserID(userID);
updateCredential(user.getId(), result.getCredential().getCredentialId(), result);
return user.getId();
}
private void storeCredential(long id,
@NotNull PublicKeyCredentialCreationOptions request,
@NotNull RegistrationResult result) {
webauthnCredentialRepository.save(fromFinishPasskeyRegistration(id, request, result));
}
private void updateCredential(long id,
@NotNull ByteArray credentialId,
@NotNull AssertionResult result) {
var entity = webauthnCredentialRepository.findAllByUserID(id).stream()
.filter(it -> credentialId.equals(it.getCredentialRegistration().getCredential().getCredentialId()))
.findAny()
.orElseThrow();
entity.getCredentialRegistration().setCredential(entity.getCredentialRegistration().getCredential().toBuilder().signatureCount(result.getSignatureCount()).build());
webauthnCredentialRepository.saveAndFlush(entity);
}
@NotNull
private static WebauthnCredentialEntity fromFinishPasskeyRegistration(long id,
PublicKeyCredentialCreationOptions request,
RegistrationResult result) {
return new WebauthnCredentialEntity(
-1,
id,
CredentialRegistration.builder()
.userIdentity(request.getUser())
.transports(result.getKeyId().getTransports().orElseGet(TreeSet::new))
.registration(Clock.systemUTC().instant())
.credential(RegisteredCredential.builder()
.credentialId(result.getKeyId().getId())
.userHandle(request.getUser().getId())
.publicKeyCose(result.getPublicKeyCose())
.signatureCount(result.getSignatureCount())
.build())
.build()
);
}
}
让我们看看其中四个 public
方法都做了什么:
PublicKeyCredentialCreationOptions startPasskeyRegistration(long userID)
,用于返回浏览器用于创建密钥所需的options
。此处我们根据userID
获取用户信息以后,调用RelyingParty.startRegistration
方法,传入StartRegistrationOptions
参数,提供了UserIdentity
信息,并指定residentKey
类型为REQUIRED
以强制要求前端对用户进行生物认证以符合 Passkey 的验证需求;然后,我们通过调用PublicKeyCredentialCreationOptions.toJSON
,将该options
序列化为 JSON 后存入 redis 中备用;最后,调用PublicKeyCredentialCreationOptions.toCredentialsCreateJson()
,生成前端所需的options
对象,返回给前端(此时,前端拿到options
后调用navigator.credentials.create
函数,要求用户身份验证,并在成功后返回公钥数据给后端)。void finishPasskeyRegistration(long userID, String credential)
,用于根据浏览器返回的公钥数据,验证 Passkey 创建是否有效。此处我们根据userID
从redis
中取回刚才存储的序列化 JSON 数据,并调用PublicKeyCredentialCreationOptions.fromJson
将其反序列回PublicKeyCredentialCreationOptions
对象;然后。调用RelyingParty.finishRegistration
方法,传入FinishRegistrationOptions
参数,提供了PublicKeyCredentialCreationOptions
对象(用于确认用于验证的密钥是哪一个),并通过调用PublicKeyCredential.parseRegistrationResponseJson
将从前端返回的公钥数据反序列化为所需的对象;最后,调用storeCredential
,将验证结果存入数据库,完成 Passkey 的创建。AssertionRequest startPasskeyAssertion(String identifier)
和long finishPasskeyAssertion(String identifier, String credential)
所做的事和上面大同小异,此处一并省略,并简单讲讲一个主要区别:此两种方法要求传入的不再是userID
而是identifier
,这是由于 Passkey 凭据认证的特殊性导致的:Passkey 认证是去用户化的,对于其他密钥,例如用于 2FA 验证的普通密钥,我们肯定会得知所需验证的用户信息,但是对于用于登录用户的 Passkey 来说,我们在用户登录前必然是不知道所登录用户的信息的,因此,在这一步,我们不必再提供用户的UserIdentity
信息(如果是其它类型的密钥则仍需要提供)。但是,我们仍需要一个唯一标识符用于确认用于验证的密钥是哪一个,因此,我们引入identifier
的机制代替原有的userID
,而其实现,就正是上文中还未解释的HttpServletRequest
之用途:通过调用HttpServletRequest.getSession().getId()
方法获取用户 Session 的唯一 ID 作为Identifier
。
如此一来,我们便完成了全部前后端逻辑的开发,完成了 Passkey 的创建和认证。
最后
本文的主干代码是从我最近正在积极开发的简易轻论坛程序 NeraBBS 中剥离出来的,为了简化示例,对原项目代码做了许多现场修改(原项目是由多个 Spring Cloud 微服务组成的,并通过 gRPC 进行数据交换,此处为了简化直接省略了这部分代码),因此可能存在一些问题,如果读者发现,请积极指正,谢谢!
参考资料
- Create a passkey for passwordless logins (web.dev)
- Web Authentication API - Web API 接口参考 | MDN (mozilla.org)
- Yubico/java-webauthn-server: Server-side Web Authentication library for Java https://www.w3.org/TR/webauthn/#rp-operations (github.com)
- github/webauthn-json: 🔏 A small WebAuthn API wrapper that translates to/from pure JSON using base64url.
- vladmihalcea/hypersistence-utils: The Hypersistence Utils library (previously known as Hibernate Types) gives you Spring and Hibernate utilities that can help you get the most out of your data access layer. (github.com)
牛
请问使用Passkey需要先联系fido联盟获取资格么?还是不用联系直接可以用?
开放标准,不需要的