使用 Spring 测试密钥/WebAuthn
万能钥匙是一项受到广泛关注的技术,考虑到在不牺牲安全性的情况下提供更多便利的承诺,这是可以理解的。
术语
依赖方- 是控制访问的实体。他们确定规则或检查以授予访问权限。
挑战- 为了防止重放攻击,WebAuthn 在注册和身份验证过程中利用挑战。挑战是随机生成的大于 16 字节的有效负载。挑战是由可信环境(服务器)中的依赖方创建的。在操作完成之前,挑战一直存在。
证明对象- 该对象的内容可以与身份验证器提供的证书或蓝图进行比较,以证明其可信。内容通常包括签名、凭证 ID 和公钥等详细信息。当依赖方收到内容时,将检查内容以确定客户端是否可信。
客户端数据 JSON - 此 JSON 对象包含与中继方相关的上下文信息,它包含诸如发出的质询、来源以及正在执行的进程类型等信息(webauthn.create 或 webauthn.get)。
我们正在测试什么?
WebAuthn 有两个主要流程:注册和身份验证。
注册- 或注册仪式是用户和依赖方(服务器)进行通信以创建和交换身份验证信息的地方。最终目标是服务器接收用户的公钥,用于在以后的身份验证尝试中进行身份验证。
身份验证- 遵循与注册类似的模式,客户端和依赖方以结构化方式交换信息。然而,这次的目标是服务器能否验证用户是否是他们声称的人。这是通过客户端使用服务器发出的私钥质询生成签名来实现的。服务器使用在注册阶段获取的公钥来验证签名。
注册流程:
- 客户端请求存储其身份验证详细信息的权限 (1.1)。
- 服务器为客户端提供实现此目的的选项以及挑战(1.3)。
- 客户端生成一个证明对象并将其返回给服务器(2.1)。
- 服务器验证响应,如果满意,则存储身份验证数据 (2.2 - 2.6)。
- 服务器返回成功,注册完成(2.7)。
认证流程
- 客户端发送其用户名请求权限进行身份验证 (1.1)。
- 服务器找到与该用户关联的密钥并生成质询(1.2 - 1.5)。
- 客户端使用其私钥根据质询创建签名并将其发送回服务器(2.1)。
- 服务器验证用户并授予访问权限(2.2 - 2.8)。
这两个流程面临的挑战是它们都有一个物理元素,即客户端的设备。
该设备负责保护客户端私钥和其他加密活动。通常,此设备是用户的计算机、USB 记忆棒或手机。
我们如何测试这个?
WebAuthn4j 提供了一个用于测试的库: mvnrepository 链接
该库包含一个模拟器实用程序,可以模拟 FIDO U2F 密钥等身份验证器。模拟器Github
我的设置
- 联合单元
- Spring引导测试
- 模拟Mvc
- 液体碱
- 测试容器 -(用于数据库等依赖项)
注意
下面的示例代码给出了一般方向,但请务必添加更多测试用例。
首先,我有一个基本抽象类,它有助于所有集成测试的一般设置以及在一个地方配置测试容器等内容:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles({"test", "console"})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class AbstractIntegrationTest {
private static final MySQLContainer<?> MYSQL_SQL_CONTAINER;
static {
MYSQL_SQL_CONTAINER = new MySQLContainer(DockerImageName.parse("mysql"))
.withDatabaseName("mydb")
.withUsername("user")
.withPassword("secret");
MYSQL_SQL_CONTAINER.withImagePullPolicy(PullPolicy.alwaysPull());
MYSQL_SQL_CONTAINER.withReuse(true);
MYSQL_SQL_CONTAINER.withEnv("MYSQL_ROOT_PASSWORD", "secret");
MYSQL_SQL_CONTAINER.start();
}
protected final ObjectWriter ow = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.findAndRegisterModules()
.writer()
.withDefaultPrettyPrinter();
@DynamicPropertySource
static void overrideTestProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL_SQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL_SQL_CONTAINER::getUsername);
registry.add("spring.datasource.password", MYSQL_SQL_CONTAINER::getPassword);
}
}
测试注册流程
为了测试注册流程,我们可以从简单的开始,让我们验证我们的注册选项是否已返回并且它包含一个挑战:
class PasskeyRegistrationControllerIntegrationTest extends AbstractIntegrationTest {
private static final String PASSKEY_URL = "/passkey";
private static final String PASSKEY_REGISTER_URL = PASSKEY_URL + "/register";
private static final String PASSKEY_REGISTER_VERIFY_URL = PASSKEY_URL + "/register-verify";
private final ObjectMapper om = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@Test
@WithMockCustomUser(userName = "billybob@bill.com")
void passkey_registration_verify_options() throws Exception {
final String rpName = "finaps";
mockMvc.perform(post(PASSKEY_REGISTER_URL)
.content(ow.writeValueAsString(PasskeyRegisterRequestDto.builder().build()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(jsonPath("$.rp.name", is(rpName)))
.andExpect(jsonPath("$.challenge", is(not(emptyString()))));
}
}
从注册图中,这应该涵盖步骤 1.1 - 1.3。
在注册和身份验证期间,服务器发出质询。这些挑战保存在服务器上,通常只有几分钟的寿命。在我的示例场景中,我只是将挑战保留在内存列表中。
@Test
@WithMockCustomUser(userName = "billybob@bill.com")
void passkey_registration_flow() throws Exception {
String regRes = mockMvc.perform(post(PASSKEY_REGISTER_URL)
.content(ow.writeValueAsString(PasskeyRegisterRequestDto.builder().build()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
PasskeyRegisterResponseDto res = om.readValue(regRes, PasskeyRegisterResponseDto.class);
ClientPlatform client = EmulatorUtil.createClientPlatform();
mockMvc.perform(post(PASSKEY_REGISTER_VERIFY_URL)
.content(ow.writeValueAsString(createPasskeyVerifyRequest(client, res)))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isNoContent());
}
对于我的完整流程测试,我调用第一个端点以从服务器获取注册设置并解析内容并将其传递到ClientPlatform
由WebAuthn4j
该方法createPasskeyVerifyRequest
是一个辅助方法,用于将我的数据传输对象转换为模拟器期望的格式并生成验证请求。
public class PasskeyEmulatorUtilities {
public static PasskeyRegisterVerifyRequestDto createPasskeyVerifyRequest(
ClientPlatform clientPlatform,
PasskeyRegisterResponseDto registerResponseDto) {
PublicKeyCredential<AuthenticatorAttestationResponse, RegistrationExtensionClientOutput> key =
createPasskey(clientPlatform, registerResponseDto);
PasskeyRegisterVerifyRequestResponseDto responseObj = PasskeyRegisterVerifyRequestResponseDto.builder()
.attestationObject(Base64UrlUtil.encodeToString(key.getAuthenticatorResponse().getAttestationObject()))
.clientDataJSON(Base64UrlUtil.encodeToString(key.getAuthenticatorResponse().getClientDataJSON()))
.build();
return PasskeyRegisterVerifyRequestDto.builder()
.id(key.getId())
.rawId(new String(key.getRawId()))
.response(responseObj)
.build();
}
public static PublicKeyCredential<AuthenticatorAttestationResponse, RegistrationExtensionClientOutput> createPasskey(
ClientPlatform clientPlatform,
PasskeyRegisterResponseDto registerResponseDto) {
return clientPlatform.create(createPublicKeyCredentialCreationOptions(registerResponseDto));
}
private static PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions(
PasskeyRegisterResponseDto registerResponseDto) {
Challenge challenge = new DefaultChallenge(registerResponseDto.getChallenge());
return new PublicKeyCredentialCreationOptions(
parseRp(registerResponseDto.getRp()),
parseUser(registerResponseDto.getUser()),
challenge,
parsePubKeys(registerResponseDto.getPubKeyCredParams()),
6000L,
Collections.emptyList(),
parseAuthenticatorSelection(registerResponseDto.getAuthenticatorSelection()),
null,
null
);
}
}
这两个测试涵盖了上面序列图中概述的“快乐流程”。但添加不愉快的流程是件好事,比如——如果挑战改变了怎么办?如果发生这种情况,就违反了登记仪式。
@Test
@WithMockCustomUser(userName = "billybob@bill.com")
void passkey_registration_flow_fails_due_to_challenge() throws Exception {
String regRes = mockMvc.perform(post(PASSKEY_REGISTER_URL)
.content(ow.writeValueAsString(PasskeyRegisterRequestDto.builder().build()))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
PasskeyRegisterResponseDto res = om.readValue(regRes, PasskeyRegisterResponseDto.class);
res.setChallenge("reallyWrongChallenge");
ClientPlatform client = EmulatorUtil.createClientPlatform();
mockMvc.perform(post(PASSKEY_REGISTER_VERIFY_URL)
.content(ow.writeValueAsString(createPasskeyVerifyRequest(client, res)))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail", is(REGISTRATION_FAILED)));
}
测试认证流程
正如我们所说,为了使身份验证发挥作用,我们需要客户端和服务器在执行身份验证之前注册彼此的信息。然而,每次测试都必须发出请求来注册密钥,然后执行登录,这会变得很混乱,更重要的是会使我们的测试变慢。
然而,webauthn4j 的模拟器使这变得很容易,在执行时,clientPlatform.get
模拟器使用允许的凭据中的标识符作为种子来生成所需的密钥对。
public static @NonNull KeyPair createKeyPair(@Nullable byte[] seed, @NonNull ECParameterSpec ecParameterSpec) {
KeyPairGenerator keyPairGenerator = createKeyPairGenerator();
SecureRandom random;
try {
if (seed != null) {
random = SecureRandom.getInstance("SHA1PRNG"); // to make it deterministic
random.setSeed(seed);
}
else {
random = secureRandom;
}
keyPairGenerator.initialize(ecParameterSpec, random);
return keyPairGenerator.generateKeyPair();
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) {
throw new UnexpectedCheckedException(e);
}
}
因此,如果我们使用之前使用模拟器生成的密钥 ID 和公钥来为数据库做种,那么应该没问题。
对于身份验证场景,我利用 liquibase 使用从模拟器获取的用户和密钥凭据预先播种数据库。
这使我能够保持身份验证测试的简单性:
class PasskeyAuthenticationControllerIntegrationTest extends AbstractIntegrationTest {
private static final String PASSKEY_URL = "/passkey";
private static final String PASSKEY_LOGIN_URL = PASSKEY_URL + "/login";
private static final String PASSKEY_LOGIN_VERIFY_URL = PASSKEY_URL + "/login-verify";
private final ObjectMapper om = new ObjectMapper();
private final PasskeyAuthenticationRequestDto userWithPasskeyRequest = PasskeyAuthenticationRequestDto.builder()
.username("passkey@smh.com")
.build();
private final PasskeyAuthenticationRequestDto userWithoutPasskey = PasskeyAuthenticationRequestDto.builder()
.username("nomates@billy.com")
.build();
@Autowired
private MockMvc mockMvc;
@Test
void passkey_login_verify_options() throws Exception {
final String rpName = "localhost";
mockMvc.perform(post(PASSKEY_LOGIN_URL)
.content(ow.writeValueAsString(userWithPasskeyRequest))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(jsonPath("$.rpId", is(rpName)))
.andExpect(jsonPath("$.challenge", is(not(emptyString()))));
}
@Test
void passkey_login_flow() throws Exception {
String regRes = mockMvc.perform(post(PASSKEY_LOGIN_URL)
.content(ow.writeValueAsString(userWithPasskeyRequest))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
PasskeyAuthenticationResponseDto res = om.readValue(regRes, PasskeyAuthenticationResponseDto.class);
ClientPlatform client = EmulatorUtil.createClientPlatform(new FIDOU2FAuthenticator());
mockMvc.perform(post(PASSKEY_LOGIN_VERIFY_URL)
.content(ow.writeValueAsString(createPasskeyAuthVerifyRequest(client, res)))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
).andExpect(status().isOk())
.andExpect(cookie().exists("refreshToken"))
.andExpect(jsonPath("$.loginStatus", is(LoginStatusDto.SUCCESS.getValue())))
.andExpect(jsonPath("$.accessToken", is(not(emptyString()))))
.andExpect(jsonPath("$.expiresIn", any(Integer.class)));
}
}
这是逻辑createPasskeyAuthVerifyRequest
public class PasskeyEmulatorUtilities {
public static PasskeyAuthenticationVerifyRequestDto createPasskeyAuthVerifyRequest(
ClientPlatform clientPlatform,
PasskeyAuthenticationResponseDto authenticationRequestDto) {
final String id = authenticationRequestDto.getAllowedCredentials().get(0).getId();
final PublicKeyCredential<AuthenticatorAssertionResponse, AuthenticationExtensionClientOutput> authenticatePasskey =
authenticatePasskey(clientPlatform, authenticationRequestDto);
final PasskeyAuthenticationVerifyRequestResponseDto response = PasskeyAuthenticationVerifyRequestResponseDto.builder()
.signature(Base64UrlUtil.encodeToString(authenticatePasskey.getAuthenticatorResponse().getSignature()))
.authenticatorData(Base64UrlUtil.encodeToString(authenticatePasskey.getAuthenticatorResponse().getAuthenticatorData()))
.clientDataJSON(Base64UrlUtil.encodeToString(authenticatePasskey.getAuthenticatorResponse().getClientDataJSON()))
.build();
return PasskeyAuthenticationVerifyRequestDto.builder()
.id(id)
.rawId(id)
.type("public-key")
.response(response)
.build();
}
public static PublicKeyCredential<AuthenticatorAssertionResponse, AuthenticationExtensionClientOutput> authenticatePasskey(
ClientPlatform clientPlatform,
PasskeyAuthenticationResponseDto authenticationRequestDto) {
return clientPlatform.get(createPublicKeyCredentialRequestOptions(authenticationRequestDto));
}
private static PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationDefaultOptions(
PasskeyRegisterResponseDto registerResponseDto) {
Challenge challenge = new DefaultChallenge(registerResponseDto.getChallenge());
return new PublicKeyCredentialCreationOptions(
parseRp(registerResponseDto.getRp()),
parseUser(registerResponseDto.getUser()),
challenge,
parsePubKeys(registerResponseDto.getPubKeyCredParams())
);
}
private static List<PublicKeyCredentialDescriptor> parsePublicKeyCredentialDescriptors(
List<PasskeyCredentialsDto> allowedCredentials) {
return allowedCredentials.stream()
.map(m -> new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY,
Base64UrlUtil.decode(m.getId()), //Don't forget to decode
null
))
.toList();
}
}
这种方法使我们能够快速建立对密钥实施的信心。我很想看看我是否可以将其提升一个档次,并利用剧作家之类的东西来控制浏览器并以这种方式执行流程。但就目前而言,这是良好的第一步。
- 点赞
- 收藏
- 关注作者
评论(0)