Skip to content

Commit eca12c6

Browse files
Add Support JdbcUserCredentialRepository
Closes gh-16224
1 parent 5a81a1f commit eca12c6

File tree

7 files changed

+625
-1
lines changed

7 files changed

+625
-1
lines changed
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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+
List<CredentialRecord> result = this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_ID_SQL,
148+
this.credentialRecordRowMapper, credentialId.toBase64UrlString());
149+
return !result.isEmpty() ? result.get(0) : null;
150+
}
151+
152+
@Override
153+
public List<CredentialRecord> findByUserId(Bytes userId) {
154+
Assert.notNull(userId, "userId cannot be null");
155+
return this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL, this.credentialRecordRowMapper,
156+
userId.toBase64UrlString());
157+
}
158+
159+
/**
160+
* Sets a {@link LobHandler} for large binary fields and large text field parameters.
161+
* @param lobHandler the lob handler
162+
*/
163+
public void setLobHandler(LobHandler lobHandler) {
164+
Assert.notNull(lobHandler, "lobHandler cannot be null");
165+
this.lobHandler = lobHandler;
166+
}
167+
168+
private static class CredentialRecordParametersMapper
169+
implements Function<CredentialRecord, List<SqlParameterValue>> {
170+
171+
@Override
172+
public List<SqlParameterValue> apply(CredentialRecord record) {
173+
List<SqlParameterValue> parameters = new ArrayList<>();
174+
175+
List<String> transports = new ArrayList<>();
176+
if (!CollectionUtils.isEmpty(record.getTransports())) {
177+
for (AuthenticatorTransport transport : record.getTransports()) {
178+
transports.add(transport.getValue());
179+
}
180+
}
181+
182+
parameters.add(new SqlParameterValue(Types.VARCHAR, record.getCredentialId().toBase64UrlString()));
183+
parameters.add(new SqlParameterValue(Types.VARCHAR, record.getUserEntityUserId().toBase64UrlString()));
184+
parameters.add(new SqlParameterValue(Types.BLOB, record.getPublicKey().getBytes()));
185+
parameters.add(new SqlParameterValue(Types.BIGINT, record.getSignatureCount()));
186+
parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isUvInitialized()));
187+
parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupEligible()));
188+
parameters.add(new SqlParameterValue(Types.VARCHAR,
189+
(!CollectionUtils.isEmpty(record.getTransports())) ? String.join(",", transports) : ""));
190+
parameters.add(new SqlParameterValue(Types.VARCHAR,
191+
(record.getCredentialType() != null) ? record.getCredentialType().getValue() : null));
192+
parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupState()));
193+
parameters.add(new SqlParameterValue(Types.BLOB,
194+
(record.getAttestationObject() != null) ? record.getAttestationObject().getBytes() : null));
195+
parameters.add(new SqlParameterValue(Types.BLOB, (record.getAttestationClientDataJSON() != null)
196+
? record.getAttestationClientDataJSON().getBytes() : null));
197+
parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getCreated())));
198+
parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getLastUsed())));
199+
parameters.add(new SqlParameterValue(Types.VARCHAR, record.getLabel()));
200+
201+
return parameters;
202+
}
203+
204+
private Timestamp fromInstant(Instant instant) {
205+
if (instant == null) {
206+
return null;
207+
}
208+
return Timestamp.from(instant);
209+
}
210+
211+
}
212+
213+
private static final class LobCreatorArgumentPreparedStatementSetter extends ArgumentPreparedStatementSetter {
214+
215+
private final LobCreator lobCreator;
216+
217+
private LobCreatorArgumentPreparedStatementSetter(LobCreator lobCreator, Object[] args) {
218+
super(args);
219+
this.lobCreator = lobCreator;
220+
}
221+
222+
@Override
223+
protected void doSetValue(PreparedStatement ps, int parameterPosition, Object argValue) throws SQLException {
224+
if (argValue instanceof SqlParameterValue paramValue) {
225+
if (paramValue.getSqlType() == Types.BLOB) {
226+
if (paramValue.getValue() != null) {
227+
Assert.isInstanceOf(byte[].class, paramValue.getValue(),
228+
"Value of blob parameter must be byte[]");
229+
}
230+
byte[] valueBytes = (byte[]) paramValue.getValue();
231+
this.lobCreator.setBlobAsBytes(ps, parameterPosition, valueBytes);
232+
return;
233+
}
234+
}
235+
super.doSetValue(ps, parameterPosition, argValue);
236+
}
237+
238+
}
239+
240+
private static class CredentialRecordRowMapper implements RowMapper<CredentialRecord> {
241+
242+
private LobHandler lobHandler = new DefaultLobHandler();
243+
244+
@Override
245+
public CredentialRecord mapRow(ResultSet rs, int rowNum) throws SQLException {
246+
Bytes credentialId = Bytes.fromBase64(new String(rs.getString("credential_id").getBytes()));
247+
Bytes userEntityUserId = Bytes.fromBase64(new String(rs.getString("user_entity_user_id").getBytes()));
248+
ImmutablePublicKeyCose publicKey = new ImmutablePublicKeyCose(
249+
this.lobHandler.getBlobAsBytes(rs, "public_key"));
250+
long signatureCount = rs.getLong("signature_count");
251+
boolean uvInitialized = rs.getBoolean("uv_initialized");
252+
boolean backupEligible = rs.getBoolean("backup_eligible");
253+
PublicKeyCredentialType credentialType = PublicKeyCredentialType
254+
.valueOf(rs.getString("public_key_credential_type"));
255+
boolean backupState = rs.getBoolean("backup_state");
256+
257+
Bytes attestationObject = null;
258+
byte[] rawAttestationObject = this.lobHandler.getBlobAsBytes(rs, "attestation_object");
259+
if (rawAttestationObject != null) {
260+
attestationObject = new Bytes(rawAttestationObject);
261+
}
262+
263+
Bytes attestationClientDataJson = null;
264+
byte[] rawAttestationClientDataJson = this.lobHandler.getBlobAsBytes(rs, "attestation_client_data_json");
265+
if (rawAttestationClientDataJson != null) {
266+
attestationClientDataJson = new Bytes(rawAttestationClientDataJson);
267+
}
268+
269+
Instant created = fromTimestamp(rs.getTimestamp("created"));
270+
Instant lastUsed = fromTimestamp(rs.getTimestamp("last_used"));
271+
String label = rs.getString("label");
272+
String[] transports = rs.getString("authenticator_transports").split(",");
273+
274+
Set<AuthenticatorTransport> authenticatorTransports = new HashSet<>();
275+
for (String transport : transports) {
276+
authenticatorTransports.add(AuthenticatorTransport.valueOf(transport));
277+
}
278+
return ImmutableCredentialRecord.builder()
279+
.credentialId(credentialId)
280+
.userEntityUserId(userEntityUserId)
281+
.publicKey(publicKey)
282+
.signatureCount(signatureCount)
283+
.uvInitialized(uvInitialized)
284+
.backupEligible(backupEligible)
285+
.credentialType(credentialType)
286+
.backupState(backupState)
287+
.attestationObject(attestationObject)
288+
.attestationClientDataJSON(attestationClientDataJson)
289+
.created(created)
290+
.label(label)
291+
.lastUsed(lastUsed)
292+
.transports(authenticatorTransports)
293+
.build();
294+
}
295+
296+
private Instant fromTimestamp(Timestamp timestamp) {
297+
if (timestamp == null) {
298+
return null;
299+
}
300+
return timestamp.toInstant();
301+
}
302+
303+
}
304+
305+
}
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
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)