Skip to content

Commit 3c87692

Browse files
Add Support JdbcUserCredentialRepository
Closes spring-projectsgh-16224
1 parent 5a81a1f commit 3c87692

File tree

7 files changed

+630
-1
lines changed

7 files changed

+630
-1
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.aot.hint;
18+
19+
import org.springframework.aot.hint.RuntimeHints;
20+
import org.springframework.aot.hint.RuntimeHintsRegistrar;
21+
import org.springframework.jdbc.core.JdbcOperations;
22+
import org.springframework.security.web.webauthn.api.CredentialRecord;
23+
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
24+
25+
/**
26+
*
27+
* A JDBC implementation of an {@link UserCredentialRepository} that uses a
28+
* {@link JdbcOperations} for {@link CredentialRecord} persistence.
29+
*
30+
* @author Max Batischev
31+
* @since 6.5
32+
*/
33+
class UserCredentialRuntimeHints implements RuntimeHintsRegistrar {
34+
35+
@Override
36+
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
37+
hints.resources().registerPattern("org/springframework/security/user-credentials-schema.sql");
38+
}
39+
40+
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.webauthn.management;
18+
19+
import java.sql.PreparedStatement;
20+
import java.sql.ResultSet;
21+
import java.sql.SQLException;
22+
import java.sql.Timestamp;
23+
import java.sql.Types;
24+
import java.time.Instant;
25+
import java.util.ArrayList;
26+
import java.util.HashSet;
27+
import java.util.List;
28+
import java.util.Set;
29+
import java.util.function.Function;
30+
31+
import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
32+
import org.springframework.jdbc.core.JdbcOperations;
33+
import org.springframework.jdbc.core.PreparedStatementSetter;
34+
import org.springframework.jdbc.core.RowMapper;
35+
import org.springframework.jdbc.core.SqlParameterValue;
36+
import org.springframework.jdbc.support.lob.DefaultLobHandler;
37+
import org.springframework.jdbc.support.lob.LobCreator;
38+
import org.springframework.jdbc.support.lob.LobHandler;
39+
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
40+
import org.springframework.security.web.webauthn.api.Bytes;
41+
import org.springframework.security.web.webauthn.api.CredentialRecord;
42+
import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord;
43+
import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose;
44+
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
45+
import org.springframework.util.Assert;
46+
import org.springframework.util.CollectionUtils;
47+
48+
/**
49+
* A JDBC implementation of an {@link UserCredentialRepository} that uses a
50+
* {@link JdbcOperations} for {@link CredentialRecord} persistence.
51+
*
52+
* <b>NOTE:</b> This {@code UserCredentialRepository} depends on the table definition
53+
* described in "classpath:org/springframework/security/user-credentials-schema.sql" and
54+
* therefore MUST be defined in the database schema.
55+
*
56+
* @author Max Batischev
57+
* @since 6.5
58+
* @see UserCredentialRepository
59+
* @see CredentialRecord
60+
* @see JdbcOperations
61+
* @see RowMapper
62+
*/
63+
public final class JdbcUserCredentialRepository implements UserCredentialRepository {
64+
65+
private RowMapper<CredentialRecord> credentialRecordRowMapper = new CredentialRecordRowMapper();
66+
67+
private Function<CredentialRecord, List<SqlParameterValue>> credentialRecordParametersMapper = new CredentialRecordParametersMapper();
68+
69+
private LobHandler lobHandler = new DefaultLobHandler();
70+
71+
private final JdbcOperations jdbcOperations;
72+
73+
private static final String TABLE_NAME = "user_credentials";
74+
75+
// @formatter:off
76+
private static final String COLUMN_NAMES = "credential_id, "
77+
+ "user_entity_user_id, "
78+
+ "public_key, "
79+
+ "signature_count, "
80+
+ "uv_initialized, "
81+
+ "backup_eligible, "
82+
+ "authenticator_transports, "
83+
+ "public_key_credential_type, "
84+
+ "backup_state, "
85+
+ "attestation_object, "
86+
+ "attestation_client_data_json, "
87+
+ "created, "
88+
+ "last_used, "
89+
+ "label ";
90+
// @formatter:on
91+
92+
// @formatter:off
93+
private static final String SAVE_CREDENTIAL_RECORD_SQL = "INSERT INTO " + TABLE_NAME
94+
+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
95+
// @formatter:on
96+
97+
private static final String ID_FILTER = "credential_id = ? ";
98+
99+
private static final String USER_ID_FILTER = "user_entity_user_id = ? ";
100+
101+
// @formatter:off
102+
private static final String FIND_CREDENTIAL_RECORD_BY_ID_SQL = "SELECT " + COLUMN_NAMES
103+
+ " FROM " + TABLE_NAME
104+
+ " WHERE " + ID_FILTER;
105+
// @formatter:on
106+
107+
// @formatter:off
108+
private static final String FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL = "SELECT " + COLUMN_NAMES
109+
+ " FROM " + TABLE_NAME
110+
+ " WHERE " + USER_ID_FILTER;
111+
// @formatter:on
112+
113+
private static final String DELETE_CREDENTIAL_RECORD_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER;
114+
115+
/**
116+
* Constructs a {@code JdbcUserCredentialRepository} using the provided parameters.
117+
* @param jdbcOperations the JDBC operations
118+
*/
119+
public JdbcUserCredentialRepository(JdbcOperations jdbcOperations) {
120+
Assert.notNull(jdbcOperations, "jdbcOperations cannot be null");
121+
this.jdbcOperations = jdbcOperations;
122+
}
123+
124+
@Override
125+
public void delete(Bytes credentialId) {
126+
Assert.notNull(credentialId, "credentialId cannot be null");
127+
SqlParameterValue[] parameters = new SqlParameterValue[] {
128+
new SqlParameterValue(Types.VARCHAR, credentialId.toBase64UrlString()), };
129+
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
130+
this.jdbcOperations.update(DELETE_CREDENTIAL_RECORD_SQL, pss);
131+
}
132+
133+
@Override
134+
public void save(CredentialRecord record) {
135+
Assert.notNull(record, "record cannot be null");
136+
List<SqlParameterValue> parameters = this.credentialRecordParametersMapper.apply(record);
137+
try (LobCreator lobCreator = this.lobHandler.getLobCreator()) {
138+
PreparedStatementSetter pss = new LobCreatorArgumentPreparedStatementSetter(lobCreator,
139+
parameters.toArray());
140+
this.jdbcOperations.update(SAVE_CREDENTIAL_RECORD_SQL, pss);
141+
}
142+
}
143+
144+
@Override
145+
public CredentialRecord findByCredentialId(Bytes credentialId) {
146+
Assert.notNull(credentialId, "credentialId cannot be null");
147+
SqlParameterValue[] parameters = new SqlParameterValue[] {
148+
new SqlParameterValue(Types.VARCHAR, credentialId.toBase64UrlString()) };
149+
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
150+
List<CredentialRecord> result = this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_ID_SQL, pss,
151+
this.credentialRecordRowMapper);
152+
return !result.isEmpty() ? result.get(0) : null;
153+
}
154+
155+
@Override
156+
public List<CredentialRecord> findByUserId(Bytes userId) {
157+
Assert.notNull(userId, "userId cannot be null");
158+
SqlParameterValue[] parameters = new SqlParameterValue[] {
159+
new SqlParameterValue(Types.VARCHAR, userId.toBase64UrlString()) };
160+
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
161+
return this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL, pss, this.credentialRecordRowMapper);
162+
}
163+
164+
/**
165+
* Sets a {@link LobHandler} for large binary fields and large text field parameters.
166+
* @param lobHandler the lob handler
167+
*/
168+
public void setLobHandler(LobHandler lobHandler) {
169+
Assert.notNull(lobHandler, "lobHandler cannot be null");
170+
this.lobHandler = lobHandler;
171+
}
172+
173+
private static class CredentialRecordParametersMapper
174+
implements Function<CredentialRecord, List<SqlParameterValue>> {
175+
176+
@Override
177+
public List<SqlParameterValue> apply(CredentialRecord record) {
178+
List<SqlParameterValue> parameters = new ArrayList<>();
179+
180+
List<String> transports = new ArrayList<>();
181+
if (!CollectionUtils.isEmpty(record.getTransports())) {
182+
for (AuthenticatorTransport transport : record.getTransports()) {
183+
transports.add(transport.getValue());
184+
}
185+
}
186+
187+
parameters.add(new SqlParameterValue(Types.VARCHAR, record.getCredentialId().toBase64UrlString()));
188+
parameters.add(new SqlParameterValue(Types.VARCHAR, record.getUserEntityUserId().toBase64UrlString()));
189+
parameters.add(new SqlParameterValue(Types.BLOB, record.getPublicKey().getBytes()));
190+
parameters.add(new SqlParameterValue(Types.BIGINT, record.getSignatureCount()));
191+
parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isUvInitialized()));
192+
parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupEligible()));
193+
parameters.add(new SqlParameterValue(Types.VARCHAR,
194+
(!CollectionUtils.isEmpty(record.getTransports())) ? String.join(",", transports) : ""));
195+
parameters.add(new SqlParameterValue(Types.VARCHAR,
196+
(record.getCredentialType() != null) ? record.getCredentialType().getValue() : null));
197+
parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupState()));
198+
parameters.add(new SqlParameterValue(Types.BLOB,
199+
(record.getAttestationObject() != null) ? record.getAttestationObject().getBytes() : null));
200+
parameters.add(new SqlParameterValue(Types.BLOB, (record.getAttestationClientDataJSON() != null)
201+
? record.getAttestationClientDataJSON().getBytes() : null));
202+
parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getCreated())));
203+
parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getLastUsed())));
204+
parameters.add(new SqlParameterValue(Types.VARCHAR, record.getLabel()));
205+
206+
return parameters;
207+
}
208+
209+
private Timestamp fromInstant(Instant instant) {
210+
if (instant == null) {
211+
return null;
212+
}
213+
return Timestamp.from(instant);
214+
}
215+
216+
}
217+
218+
private static final class LobCreatorArgumentPreparedStatementSetter extends ArgumentPreparedStatementSetter {
219+
220+
private final LobCreator lobCreator;
221+
222+
private LobCreatorArgumentPreparedStatementSetter(LobCreator lobCreator, Object[] args) {
223+
super(args);
224+
this.lobCreator = lobCreator;
225+
}
226+
227+
@Override
228+
protected void doSetValue(PreparedStatement ps, int parameterPosition, Object argValue) throws SQLException {
229+
if (argValue instanceof SqlParameterValue paramValue) {
230+
if (paramValue.getSqlType() == Types.BLOB) {
231+
if (paramValue.getValue() != null) {
232+
Assert.isInstanceOf(byte[].class, paramValue.getValue(),
233+
"Value of blob parameter must be byte[]");
234+
}
235+
byte[] valueBytes = (byte[]) paramValue.getValue();
236+
this.lobCreator.setBlobAsBytes(ps, parameterPosition, valueBytes);
237+
return;
238+
}
239+
}
240+
super.doSetValue(ps, parameterPosition, argValue);
241+
}
242+
243+
}
244+
245+
private static class CredentialRecordRowMapper implements RowMapper<CredentialRecord> {
246+
247+
private LobHandler lobHandler = new DefaultLobHandler();
248+
249+
@Override
250+
public CredentialRecord mapRow(ResultSet rs, int rowNum) throws SQLException {
251+
Bytes credentialId = Bytes.fromBase64(new String(rs.getString("credential_id").getBytes()));
252+
Bytes userEntityUserId = Bytes.fromBase64(new String(rs.getString("user_entity_user_id").getBytes()));
253+
ImmutablePublicKeyCose publicKey = new ImmutablePublicKeyCose(
254+
this.lobHandler.getBlobAsBytes(rs, "public_key"));
255+
long signatureCount = rs.getLong("signature_count");
256+
boolean uvInitialized = rs.getBoolean("uv_initialized");
257+
boolean backupEligible = rs.getBoolean("backup_eligible");
258+
PublicKeyCredentialType credentialType = PublicKeyCredentialType
259+
.valueOf(rs.getString("public_key_credential_type"));
260+
boolean backupState = rs.getBoolean("backup_state");
261+
262+
Bytes attestationObject = null;
263+
byte[] rawAttestationObject = this.lobHandler.getBlobAsBytes(rs, "attestation_object");
264+
if (rawAttestationObject != null) {
265+
attestationObject = new Bytes(rawAttestationObject);
266+
}
267+
268+
Bytes attestationClientDataJson = null;
269+
byte[] rawAttestationClientDataJson = this.lobHandler.getBlobAsBytes(rs, "attestation_client_data_json");
270+
if (rawAttestationClientDataJson != null) {
271+
attestationClientDataJson = new Bytes(rawAttestationClientDataJson);
272+
}
273+
274+
Instant created = fromTimestamp(rs.getTimestamp("created"));
275+
Instant lastUsed = fromTimestamp(rs.getTimestamp("last_used"));
276+
String label = rs.getString("label");
277+
String[] transports = rs.getString("authenticator_transports").split(",");
278+
279+
Set<AuthenticatorTransport> authenticatorTransports = new HashSet<>();
280+
for (String transport : transports) {
281+
authenticatorTransports.add(AuthenticatorTransport.valueOf(transport));
282+
}
283+
return ImmutableCredentialRecord.builder()
284+
.credentialId(credentialId)
285+
.userEntityUserId(userEntityUserId)
286+
.publicKey(publicKey)
287+
.signatureCount(signatureCount)
288+
.uvInitialized(uvInitialized)
289+
.backupEligible(backupEligible)
290+
.credentialType(credentialType)
291+
.backupState(backupState)
292+
.attestationObject(attestationObject)
293+
.attestationClientDataJSON(attestationClientDataJson)
294+
.created(created)
295+
.label(label)
296+
.lastUsed(lastUsed)
297+
.transports(authenticatorTransports)
298+
.build();
299+
}
300+
301+
private Instant fromTimestamp(Timestamp timestamp) {
302+
if (timestamp == null) {
303+
return null;
304+
}
305+
return timestamp.toInstant();
306+
}
307+
308+
}
309+
310+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
org.springframework.aot.hint.RuntimeHintsRegistrar=\
2-
org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints
2+
org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints,\
3+
org.springframework.security.web.aot.hint.UserCredentialRuntimeHints
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
create table user_credentials
2+
(
3+
credential_id varchar(1000) not null,
4+
user_entity_user_id varchar(1000) not null,
5+
public_key blob not null,
6+
signature_count bigint,
7+
uv_initialized boolean,
8+
backup_eligible boolean not null,
9+
authenticator_transports varchar(1000),
10+
public_key_credential_type varchar(100),
11+
backup_state boolean not null,
12+
attestation_object blob,
13+
attestation_client_data_json blob,
14+
created timestamp,
15+
last_used timestamp,
16+
label varchar(1000) not null,
17+
primary key (credential_id)
18+
);

0 commit comments

Comments
 (0)