From 9b8c42e3ffe8b1937bbc84a680ab4f514f9c59f6 Mon Sep 17 00:00:00 2001 From: Andrey Litvitski Date: Sat, 9 May 2026 16:54:56 +0300 Subject: [PATCH] Migrate JdbcTokenRepositoryImpl to JdbcPersistentTokenRepository Since `JdbcDaoSupport` has been deprecated, we should deprecate our implementation of `JdbcTokenRepositoryImpl`, which directly inherits from `JdbcDaoSupport`. Instead of using `JdbcDaoSupport`, we are advised to inject `JdbcTemplate` or `JdbcClient` into the field, so we should create a new implementation of `JdbcPersistentTokenRepository`. References: gh-18982 Closes: gh-18987, gh-18986 Signed-off-by: Andrey Litvitski --- .../JdbcPersistentTokenRepository.java | 155 ++++++++++++++ .../rememberme/JdbcTokenRepositoryImpl.java | 2 + .../rememberme/PersistentTokenRepository.java | 1 + .../JdbcPersistentTokenRepositoryTests.java | 199 ++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepository.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepositoryTests.java diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepository.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepository.java new file mode 100644 index 00000000000..f7765b69a92 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepository.java @@ -0,0 +1,155 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.authentication.rememberme; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Date; + +import javax.sql.DataSource; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.log.LogMessage; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * JDBC based persistent login token repository implementation. + * + * @author Andrey Litvitski + * @since 7.1.0 + */ +public class JdbcPersistentTokenRepository implements PersistentTokenRepository { + + /** Default SQL for creating the database table to store the tokens */ + public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, " + + "token varchar(64) not null, last_used timestamp not null)"; + + /** The default SQL used by the getTokenBySeries query */ + public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?"; + + /** The default SQL used by createNewToken */ + public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)"; + + /** The default SQL used by updateToken */ + public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?"; + + /** The default SQL used by removeUserTokens */ + public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?"; + + private String tokensBySeriesSql = DEF_TOKEN_BY_SERIES_SQL; + + private String insertTokenSql = DEF_INSERT_TOKEN_SQL; + + private String updateTokenSql = DEF_UPDATE_TOKEN_SQL; + + private String removeUserTokensSql = DEF_REMOVE_USER_TOKENS_SQL; + + private boolean createTableOnStartup; + + private final JdbcClient jdbcClient; + + protected final Log logger = LogFactory.getLog(this.getClass()); + + public JdbcPersistentTokenRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + public JdbcPersistentTokenRepository(DataSource dataSource) { + this.jdbcClient = JdbcClient.create(dataSource); + } + + public JdbcPersistentTokenRepository(JdbcTemplate jdbcTemplate) { + this.jdbcClient = JdbcClient.create(jdbcTemplate); + } + + @Override + public void createNewToken(PersistentRememberMeToken token) { + this.jdbcClient.sql(this.insertTokenSql) + .param(token.getUsername()) + .param(token.getSeries()) + .param(token.getTokenValue()) + .param(token.getDate()) + .update(); + } + + @Override + public void updateToken(String series, String tokenValue, Date lastUsed) { + this.jdbcClient.sql(this.updateTokenSql).param(tokenValue).param(lastUsed).param(series).update(); + } + + /** + * Loads the token data for the supplied series identifier. If an error occurs, it + * will be reported and null will be returned (since the result should just be a + * failed persistent login). + * @param seriesId + * @return the token matching the series, or null if no match found or an exception + * occurred. + */ + @Override + public @Nullable PersistentRememberMeToken getTokenForSeries(String seriesId) { + try { + return this.jdbcClient.sql(this.tokensBySeriesSql) + .param(seriesId) + .query(this::createRememberMeToken) + .single(); + } + catch (EmptyResultDataAccessException ex) { + this.logger.debug(LogMessage.format("Querying token for series '%s' returned no results.", seriesId), ex); + } + catch (IncorrectResultSizeDataAccessException ex) { + this.logger.error(LogMessage.format( + "Querying token for series '%s' returned more than one value. Series" + " should be unique", + seriesId)); + } + catch (DataAccessException ex) { + this.logger.error("Failed to load token for series " + seriesId, ex); + } + return null; + } + + private PersistentRememberMeToken createRememberMeToken(ResultSet rs, int rowNum) throws SQLException { + return new PersistentRememberMeToken(rs.getString(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4)); + } + + @Override + public void removeUserTokens(String username) { + this.jdbcClient.sql(this.removeUserTokensSql).param(username).update(); + } + + /** + * Intended for convenience in debugging. Will create the persistent_tokens database + * table when the class is initialized during the initDao method. + * @param createTableOnStartup set to true to execute the + */ + public void setCreateTableOnStartup(boolean createTableOnStartup) { + this.createTableOnStartup = createTableOnStartup; + } + + protected void initDao() { + if (this.createTableOnStartup) { + this.jdbcClient.sql(CREATE_TABLE_SQL).update(); + } + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java index 0b2179835a9..113083c0013 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java @@ -34,7 +34,9 @@ * * @author Luke Taylor * @since 2.0 + * @deprecated Use {@link JdbcPersistentTokenRepository} */ +@Deprecated(since = "7.1.0") @SuppressWarnings("removal") public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository { diff --git a/web/src/main/java/org/springframework/security/web/authentication/rememberme/PersistentTokenRepository.java b/web/src/main/java/org/springframework/security/web/authentication/rememberme/PersistentTokenRepository.java index 5e633e05de5..b1a9b22f63c 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/rememberme/PersistentTokenRepository.java +++ b/web/src/main/java/org/springframework/security/web/authentication/rememberme/PersistentTokenRepository.java @@ -28,6 +28,7 @@ * @since 2.0 * @see JdbcTokenRepositoryImpl * @see InMemoryTokenRepositoryImpl + * @see JdbcPersistentTokenRepository */ public interface PersistentTokenRepository { diff --git a/web/src/test/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepositoryTests.java b/web/src/test/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepositoryTests.java new file mode 100644 index 00000000000..7423155c190 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/rememberme/JdbcPersistentTokenRepositoryTests.java @@ -0,0 +1,199 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.authentication.rememberme; + +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.jdbc.datasource.SingleConnectionDataSource; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.then; + +/** + * @author Andrey Litvitski + */ +@ExtendWith(MockitoExtension.class) +public class JdbcPersistentTokenRepositoryTests { + + @Mock + private Log logger; + + private static SingleConnectionDataSource dataSource; + + private JdbcPersistentTokenRepository repo; + + private JdbcClient client; + + @BeforeAll + public static void createDataSource() { + dataSource = new SingleConnectionDataSource("jdbc:hsqldb:mem:tokenrepotest", "sa", "", true); + dataSource.setDriverClassName("org.hsqldb.jdbc.JDBCDriver"); + } + + @AfterAll + public static void clearDataSource() { + dataSource.destroy(); + dataSource = null; + } + + @BeforeEach + public void populateDatabase() { + this.client = JdbcClient.create(dataSource); + this.client + .sql("create table persistent_logins (username varchar(100) not null, " + + "series varchar(100) not null, token varchar(500) not null, last_used timestamp not null)") + .update(); + this.repo = new JdbcPersistentTokenRepository(this.client); + ReflectionTestUtils.setField(this.repo, "logger", this.logger); + this.repo.initDao(); + } + + @AfterEach + public void clearData() { + this.client.sql("drop table persistent_logins").update(); + } + + @Test + public void createNewTokenInsertsCorrectData() { + Timestamp currentDate = new Timestamp(Calendar.getInstance().getTimeInMillis()); + PersistentRememberMeToken token = new PersistentRememberMeToken("joeuser", "joesseries", "atoken", currentDate); + this.repo.createNewToken(token); + Map results = this.client.sql("select * from persistent_logins").query().singleRow(); + assertThat(results).containsEntry("last_used", currentDate); + assertThat(results).containsEntry("username", "joeuser"); + assertThat(results).containsEntry("series", "joesseries"); + assertThat(results).containsEntry("token", "atoken"); + } + + @Test + public void retrievingTokenReturnsCorrectData() { + this.client + .sql("insert into persistent_logins (series, username, token, last_used) values " + + "('joesseries', 'joeuser', 'atoken', '2007-10-09 18:19:25.000000000')") + .update(); + PersistentRememberMeToken token = this.repo.getTokenForSeries("joesseries"); + assertThat(token.getUsername()).isEqualTo("joeuser"); + assertThat(token.getSeries()).isEqualTo("joesseries"); + assertThat(token.getTokenValue()).isEqualTo("atoken"); + assertThat(token.getDate()).isEqualTo(Timestamp.valueOf("2007-10-09 18:19:25.000000000")); + } + + @Test + public void retrievingTokenWithDuplicateSeriesReturnsNull() { + this.client + .sql("insert into persistent_logins (series, username, token, last_used) values " + + "('joesseries', 'joeuser', 'atoken2', '2007-10-19 18:19:25.000000000')") + .update(); + this.client + .sql("insert into persistent_logins (series, username, token, last_used) values " + + "('joesseries', 'joeuser', 'atoken', '2007-10-09 18:19:25.000000000')") + .update(); + assertThat(this.repo.getTokenForSeries("joesseries")).isNull(); + } + + // SEC-1964 + @Test + public void retrievingTokenWithNoSeriesReturnsNull() { + assertThat(this.repo.getTokenForSeries("missingSeries")).isNull(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + then(this.logger).should().debug(captor.capture(), any(EmptyResultDataAccessException.class)); + then(this.logger).shouldHaveNoMoreInteractions(); + assertThat(captor.getValue()).hasToString("Querying token for series 'missingSeries' returned no results."); + } + + @Test + public void removingUserTokensDeletesData() { + this.client + .sql("insert into persistent_logins (series, username, token, last_used) values " + + "('joesseries2', 'joeuser', 'atoken2', '2007-10-19 18:19:25.000000000')") + .update(); + this.client + .sql("insert into persistent_logins (series, username, token, last_used) values " + + "('joesseries', 'joeuser', 'atoken', '2007-10-09 18:19:25.000000000')") + .update(); + this.repo.removeUserTokens("joeuser"); + List> results = this.client + .sql("select * from persistent_logins where username = 'joeuser'") + .query() + .listOfRows(); + assertThat(results).isEmpty(); + } + + @Test + public void updatingTokenModifiesTokenValueAndLastUsed() { + Timestamp ts = new Timestamp(System.currentTimeMillis() - 1); + this.client + .sql("insert into persistent_logins (series, username, token, last_used) values " + + "('joesseries', 'joeuser', 'atoken', '" + ts + "')") + .update(); + this.repo.updateToken("joesseries", "newtoken", new Date()); + Map results = this.client.sql("select * from persistent_logins where series = 'joesseries'") + .query() + .singleRow(); + assertThat(results).containsEntry("username", "joeuser"); + assertThat(results).containsEntry("series", "joesseries"); + assertThat(results).containsEntry("token", "newtoken"); + Date lastUsed = (Date) results.get("last_used"); + assertThat(lastUsed.getTime() > ts.getTime()).isTrue(); + } + + @Test + public void createTableOnStartupCreatesCorrectTable() { + this.client.sql("drop table persistent_logins").update(); + this.repo = new JdbcPersistentTokenRepository(this.client); + this.repo.setCreateTableOnStartup(true); + this.repo.initDao(); + this.client.sql("select username,series,token,last_used from persistent_logins").query().listOfRows(); + } + + // SEC-2879 + @Test + public void updateUsesLastUsed() { + JdbcClient mockClient = mock(JdbcClient.class); + JdbcClient.StatementSpec statementSpec = mock(JdbcClient.StatementSpec.class); + Date lastUsed = new Date(1424841314059L); + given(mockClient.sql(anyString())).willReturn(statementSpec); + given(statementSpec.param(any())).willReturn(statementSpec); + JdbcPersistentTokenRepository repository = new JdbcPersistentTokenRepository(mockClient); + repository.updateToken("series", "token", lastUsed); + then(statementSpec).should().param(eq(lastUsed)); + } + +}