Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <tt>getTokenBySeries</tt> 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 <tt>createNewToken</tt> */
public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";

/** The default SQL used by <tt>updateToken</tt> */
public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";

/** The default SQL used by <tt>removeUserTokens</tt> */
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);
}

Comment on lines +69 to +85

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great opportunity for us to start using the new JdbcClient API, which is fully compatible with the existing JdbcClient. Nice!

@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();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +39 to 41

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't mark it as "forRemoval" because otherwise the build would fail.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* @since 2.0
* @see JdbcTokenRepositoryImpl
* @see InMemoryTokenRepositoryImpl
* @see JdbcPersistentTokenRepository
*/
public interface PersistentTokenRepository {

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<Object> 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<Map<String, Object>> 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<String, Object> 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));
}

}
Loading