Loading [MathJax]/jax/output/CommonHTML/config.js
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >实战!为你的网站接入 Passkey 通行密钥以实现无密码安全登录

实战!为你的网站接入 Passkey 通行密钥以实现无密码安全登录

作者头像
HikariLan贺兰星辰
发布于 2023-10-18 07:37:56
发布于 2023-10-18 07:37:56
3.3K01
代码可运行
举报
文章被收录于专栏:HikariLan's BlogHikariLan's Blog
运行总次数:1
代码可运行

实战!为你的网站接入 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 为我们提供了两个主要方法:

通过这两个方法,我们可以将 Web 认证凭据的创建和认证过程大致拆分为以下几部分:

凭据创建

  1. 浏览器向服务器发起请求,获取凭据创建所需的 options 信息(例如站点 ID,用户信息,防重放 challenge 等);
  2. 浏览器调用 navigator.credentials.create() 方法,传入上一步获取的 options,浏览器调用操作系统接口弹出对话框要求用户进行身份验证以创建密钥;
  3. 如果用户身份验证成功,那么浏览器则应该向服务器发起请求,返回上一步调用方法的返回值;服务器将对该值进行验证,如果验证通过,则将相关信息存储到数据库中,此时凭据创建成功;

凭据认证

  1. 浏览器向服务器发起请求,获取凭据认证所需的 options 信息(例如站点 ID,防重放 challenge 等);
  2. 浏览器调用 navigator.credentials.get() 方法,传入上一步获取的 options,浏览器调用操作系统接口弹出对话框要求用户选择进行身份验证的密钥并进行身份验证;
  3. 如果用户身份验证成功,那么浏览器则应该向服务器发起请求,返回上一步调用方法的返回值;服务器将对该值进行验证,如果验证通过,则凭据认证成功,服务器可在更新密钥信息后将用户登录到站点(或者通过 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 接口:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 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,作为数据库实体类,完成数据表结构构造:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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 数据类的构造如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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 字段作为唯一标识符标识唯一用户,namedisplayName 则只是为用户提供人类可读的文本信息用以标识该账户的名称;
  • 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:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@Repository
public interface WebauthnCredentialRepository extends JpaRepository<WebauthnCredentialEntity, Long> {

    // 根据用户 ID 获取该用户的所有凭据信息
    List<WebauthnCredentialEntity> findAllByUserID(long userID);

}

然后,创建 CredentialRepositoryImpl 类,实现 CredentialRepository 接口:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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 操作的入口点,我们需要为其传入 idname 进行构造,这对应了 Webauthn API 上 options 中的 rp 字段:

  • id 代表供应商 ID,应当是一段域名,该域名必须和实际服务域名完全符合(或者填入顶级域名来匹配根域名和所有二级域名);
  • name 代表供应商名称,可随意填写。

值得一提的是,为了安全起见,浏览器上的 Webauthn API 仅会接受来自 HTTPS 连接的网站调用其 API(或者本地回环地址 localhost,可以免于采用 HTTPS 连接);对于其他情况,该 API 会返回 undefined

接下来,创建 WebauthnConfiguration 类,构造 RelyingParty 类并将其注入 Spring Bean 容器中:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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,返回数据。根据这个思路,我们可以分别创建以下四个路由

  1. 返回 Web 凭证创建所需的 options
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
GET /api/authorization/passkey/registration/options
  1. 接受前端调用凭证创建 API 后返回的凭证信息:
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
POST /api/authorization/passkey/registration
{
 ...
}
  1. 返回 Web 凭证认证所需的 options
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
GET /api/authorization/passkey/assertion/options
  1. 接受前端调用凭证校验 API 后返回的凭证信息:
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
POST /api/authorization/passkey/assertion

根据这个思路,在后端创建 PasskeyAuthorizationController

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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 的创建和认证:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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);

        awaitfetch("/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 的实现:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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 创建是否有效。此处我们根据 userIDredis 中取回刚才存储的序列化 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 进行数据交换,此处为了简化直接省略了这部分代码),因此可能存在一些问题,如果读者发现,请积极指正,谢谢!

参考资料

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-8-23 2,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
深入解析Passkeys背后的密码学原理
当大多数人想到密码学时,首先想到的通常是加密:保持信息机密性。但同样重要(甚至更重要)的是真实性:确保信息确实来自真实来源。当您访问网站时,服务器通常通过Web公钥基础设施(PKI)认证的传输层安全(TLS)证书来证明其身份。密码是用户认证的传统解决方案,但它们容易受到钓鱼攻击和数据泄露的影响。这就是Passkeys的用武之地。
用户4682003
2025/07/26
1170
Next Terminal 实战:内网无密码安全登录
在日常的 HomeLab 或小型私有云环境中,我们常常通过反向代理(如 Nginx、Caddy 等)将内网服务暴露到公网,方便远程访问。然而,一旦端口映射开启、公网可达,安全问题便随之而来:未经身份验证的访问、暴力破解、爬虫扫描、甚至未授权的数据泄露,都可能悄无声息地发生。
用户9171652
2025/08/04
660
【第十篇】单点登录原理和JWT实现
单点登录原理及JWT实现 波波烤鸭 一、单点登录效果   首先我们看通过一个具体的案例来加深对单点登录的理解。案例地址:https://gitee.com/xuxueli0323/xxl-s
用户4919348
2022/05/23
1.2K0
【第十篇】单点登录原理和JWT实现
Yii框架应用程序整合Ucenter实现同步注册、登录和退出等
如今很多网站都要整合论坛程序,而康盛的Discuz系列产品往往是首选。然后就有了整合用户的需要,康盛提供了Ucenter架构,方便对不同的应用程序进行单点登录整合。 进来我尝试将ucenter整合到Yii网站中,获得了成功,虽然登录同步程序不是很妥当,基本使用没有问题了。我将继续改进。下面说说步骤: 下载安装ucenter和discuz,我使用的是ucenter1.6和discuz7.2,由于7.2自带的uc_client是旧版本,所以需要覆盖一下1.6版本。 复制一份uc_client文件夹到 prote
joshua317
2018/04/16
1.9K0
java小技能:JWT(json web token)认证实现
引言 应用场景: 登录授权,它相比原先的session、cookie来说,更快更安全,跨域也不再是问题。 传递数据 I. 预备知识 1.1 关键字去空格处理 错误代码 (keyword+"").
公众号iOS逆向
2022/12/19
2.3K0
java小技能:JWT(json web token)认证实现
Microsoft.AspNet.Identity 自定义使用现有的表—登录实现
Microsoft.AspNet.Identity是微软新引入的一种membership框架,也是微软Owin标准的一个实现。Microsoft.AspNet.Identity.EntityFramework则是Microsoft.AspNet.Identity的数据提供实现。但是在使用此框架的时候存在一些问题,如果是全新的项目还可以使用它默认提供的表名,字段名等。但是如果是在一些老的数据库上应用这个框架就比较麻烦了。所以我们实现一个自己的Microsoft.AspNet.Identity.EntityFramework
旺财的城堡
2018/11/20
2K0
谷歌正式推出 “密钥登录”,逐步取代传统密码登录
出品 | OSC开源社区(ID:oschina2013) 都什么年代了,还在用传统密码?10 月 12 日,谷歌宣布在 Android 和 Chrome 中正式推行密钥登录 “PassKey”,以逐步替代长期使用的密码登录 “PassWord”。 推出的密钥登录可以认为是 “生物密码” 和 “授权登录” 的结合。用户可以在 Android 手机上创建一个基于公钥加密的密钥凭据,创建密钥的时候需要对本人进行生物特征识别,比如 “指纹” 或者 “面部识别” 等。 创建完毕后,这个密钥凭据可用于解锁所有在线
程序猿DD
2023/04/04
8580
谷歌正式推出 “密钥登录”,逐步取代传统密码登录
JWT单点登录代码实现(Demo详解)
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
全栈程序员站长
2022/08/31
6380
JWT单点登录代码实现(Demo详解)
SpringSecurity 实现几种常见的登录方式
SpringSecurity 要求配置类继承 WebSecurityConfigurerAdapter,并重写其中的 configure 方法。我们先进行基本的配置:
1270778
2024/02/05
1K0
从 am start 的 --user 参数说到 Android 多用户
本文的讨论围绕一个 java.lang.SecurityException 展开,异常的关键词是权限 android.permission.INTERACT_ACROSS_USERS_FULL。
mzlogin
2020/04/16
2.9K1
从 am start 的 --user 参数说到 Android 多用户
SpringBoot整合微信登录
终有救赎
2023/10/16
1.2K0
SpringBoot整合微信登录
C#中HttpWebRequest的用法详解
HttpWebRequest和HttpWebResponse类是用于发送和接收HTTP数据的最好选择。它们支持一系列有用的属性。这两个类位 于System.Net命名空间,默认情况下这个类对于控制台程序来说是可访问的。请注意,HttpWebRequest对象不是利用new关键字通过构 造函数来创建的,而是利用工厂机制(factory mechanism)通过Create()方法来创建的。另外,你可能预计需要显式地调用一个“Send”方法,实际上不需要。接下来调用 HttpWebRequest.GetResponse()方法返回的是一个HttpWebResponse对象。你可以把HTTP响应的数据流 (stream)绑定到一个StreamReader对象,然后就可以通过ReadToEnd()方法把整个HTTP响应作为一个字符串取回。也可以通过 StreamReader.ReadLine()方法逐行取回HTTP响应的内容。
全栈程序员站长
2022/09/14
4.9K0
SpringSecurity学习
其核心就是一组过滤器链,项目启动后将会自动配置。最核心的就是 Basic Authentication Filter 用来认证用户的身份,一个在spring security中一种过滤器处理一种认证方式
云边小卖部
2022/12/02
7590
实现Web端指纹登录
现在越来越多的笔记本电脑内置了指纹识别,用于快速从锁屏进入桌面,一些客户端的软件也支持通过指纹来认证用户身份。
神奇的程序员
2022/04/10
2.2K0
实现Web端指纹登录
25.<Spring博客系统②(实现JWT令牌登录接口+强制登录+获取用户信息+获取作者信息)>
存储token。登录后按f12。点击应用程序,找到本地存储。就能看到我们存储的token了
用户11288958
2024/11/21
4540
25.<Spring博客系统②(实现JWT令牌登录接口+强制登录+获取用户信息+获取作者信息)>
用户登录与AD域集成[通俗易懂]
AD的全称是Active Directory:活动目录 域(Domain): 1)域是Windows网络中独立运行的单位,域之间相互访问则需要建立信任关系(即Trust Relation)。信任关系是连接在域与域之间的桥梁。当一个域与其他域建立了信任关系后 2)两个域之间不但可以按需要相互进行管理,还可以跨网分配文件和打印机等设备资源,使不同的域之间实现网络资源的共享与管理,以及相互通信和数据传输 域控制器(DC): 域控制器就是一台服务器,负责每一台联入网络的电脑和用户的验证工作。 组织单元(OU) 用户名服务器名(CN)
全栈程序员站长
2022/08/29
3.4K0
用户登录与AD域集成[通俗易懂]
Next.js 实战 (九):使用 next-auth 完成第三方身份登录验证
next-auth 是一个专门为 Next.js 设计的、易于使用的、灵活的身份验证库。它简化了为你的应用程序添加身份验证(如登录、注册、登出等)的过程。next-auth 支持多种认证方式,包括通过电子邮件和密码、OAuth 2.0 提供商(如 Google、GitHub、Facebook 等)、以及自定义提供商。
白雾茫茫丶
2025/01/17
6400
Next.js 实战 (九):使用 next-auth 完成第三方身份登录验证
手把手教你,前后端全流程的注册、登录、下单!
在互联网大厂这些年做研发这么多年,有一个非常指导性的开发原则就是;你做的这个东西是否能让整个大组内的其他系统使用。所以,从15年入职开始,我有的各种创新的想法都落地实现了,一直被使用到现在。那些组件也都成了一个个技术专利 👍🏻
小傅哥
2025/08/11
520
手把手教你,前后端全流程的注册、登录、下单!
「实用教程」登录失败超过一定次数如何锁定帐号?
本教程作者是「小灯光环」,作者简介:全栈开发工程师,CSDN博客专家,CSDN论坛 Java Web/Java EE版主,热爱技术,乐于分享,在分布式Web开发/Android开发/微信小程序开发/Linux系统优化等方面均有一定经验,欢迎点击文章底部的阅读原文关注作者博客。
用户1093975
2018/08/16
3.4K0
「实用教程」登录失败超过一定次数如何锁定帐号?
37000 字 + 代码,艿艿肝的 Shiro 从入门到实战,直接收藏吃灰!
大家好,我是艿艿,一个让你秃头的小胖子。。。 最近状态有点小好,抠脚一算, https://github.com/YunaiV/SpringBoot-Labs 仓库的 Spring Boot、Spring Cloud、Dubbo 的示例代码,竟然要破 60000 行了 = = 默默撸了 2 年了要~
芋道源码
2020/06/24
2.6K0
推荐阅读
相关推荐
深入解析Passkeys背后的密码学原理
更多 >
交个朋友
加入[数据] 腾讯云技术交流站
获取数据实战干货 共享技术经验心得
加入数据技术工作实战群
获取实战干货 交流技术经验
加入[数据库] 腾讯云官方技术交流站
数据库问题秒解答 分享实践经验
换一批
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验