From 89874a4bfb2fa0e3be91471e77f94757b5eb1b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 1 Jan 2026 14:37:36 +0100 Subject: [PATCH] Retain twitch tokens --- ...uth2AuthorizedClientPersistenceConfig.java | 24 +++ .../SQLiteOAuth2AuthorizedClientService.java | 162 ++++++++++++++++++ .../imgfloat/config/SchemaMigration.java | 48 ++++++ .../imgfloat/config/SecurityConfig.java | 5 +- .../controller/ChannelApiController.java | 25 ++- src/main/resources/application.yml | 3 + 6 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java new file mode 100644 index 0000000..5da7b36 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java @@ -0,0 +1,24 @@ +package dev.kruhlmann.imgfloat.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; + +@Configuration +public class OAuth2AuthorizedClientPersistenceConfig { + + @Bean + OAuth2AuthorizedClientService oauth2AuthorizedClientService(JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository) { + return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); + } + + @Bean + OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) { + return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java b/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java new file mode 100644 index 0000000..b61bf5a --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java @@ -0,0 +1,162 @@ +package dev.kruhlmann.imgfloat.config; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; + +public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService { + private static final String TABLE_NAME = "oauth2_authorized_client"; + + private final JdbcOperations jdbcOperations; + private final ClientRegistrationRepository clientRegistrationRepository; + private final RowMapper rowMapper; + + public SQLiteOAuth2AuthorizedClientService(JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository) { + this.jdbcOperations = jdbcOperations; + this.clientRegistrationRepository = clientRegistrationRepository; + this.rowMapper = (rs, rowNum) -> { + String registrationId = rs.getString("client_registration_id"); + String principalName = rs.getString("principal_name"); + ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(registrationId); + if (registration == null) { + return null; + } + + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + rs.getString("access_token_value"), + toInstant(rs.getObject("access_token_issued_at")), + toInstant(rs.getObject("access_token_expires_at")), + scopesFrom(rs.getString("access_token_scopes")) + ); + + Object refreshValue = rs.getObject("refresh_token_value"); + OAuth2RefreshToken refreshToken = refreshValue == null + ? null + : new OAuth2RefreshToken( + refreshValue.toString(), + toInstant(rs.getObject("refresh_token_issued_at")) + ); + + return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken); + }; + } + + @Override + public T loadAuthorizedClient(String clientRegistrationId, String principalName) { + return jdbcOperations.query( + "SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " + + "FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", + ps -> { + ps.setString(1, clientRegistrationId); + ps.setString(2, principalName); + }, + rs -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null + ); + } + + @Override + public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) { + try { + int updated = jdbcOperations.update(""" + INSERT INTO oauth2_authorized_client ( + client_registration_id, principal_name, + access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, + refresh_token_value, refresh_token_issued_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET + access_token_value=excluded.access_token_value, + access_token_issued_at=excluded.access_token_issued_at, + access_token_expires_at=excluded.access_token_expires_at, + access_token_scopes=excluded.access_token_scopes, + refresh_token_value=excluded.refresh_token_value, + refresh_token_issued_at=excluded.refresh_token_issued_at + """, + preparedStatement -> { + preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId()); + preparedStatement.setString(2, principal.getName()); + setToken(preparedStatement, 3, authorizedClient.getAccessToken()); + preparedStatement.setObject(5, toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()), java.sql.Types.BIGINT); + preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes())); + OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken(); + if (refreshToken != null) { + preparedStatement.setString(7, refreshToken.getTokenValue()); + preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT); + } else { + preparedStatement.setNull(7, java.sql.Types.VARCHAR); + preparedStatement.setNull(8, java.sql.Types.BIGINT); + } + }); + } catch (DataAccessException ex) { + throw ex; + } + } + + @Override + public void removeAuthorizedClient(String clientRegistrationId, String principalName) { + jdbcOperations.update("DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", + preparedStatement -> { + preparedStatement.setString(1, clientRegistrationId); + preparedStatement.setString(2, principalName); + }); + } + + private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token) throws java.sql.SQLException { + ps.setString(startIndex, token.getTokenValue()); + ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT); + } + + private Instant toInstant(Object value) { + if (value == null) { + return null; + } + if (value instanceof Timestamp ts) { + return ts.toInstant(); + } + if (value instanceof Number num) { + return Instant.ofEpochMilli(num.longValue()); + } + String text = value.toString(); + try { + long millis = Long.parseLong(text); + return Instant.ofEpochMilli(millis); + } catch (NumberFormatException ex) { + return Timestamp.valueOf(text).toInstant(); + } + } + + private Long toEpochMillis(Instant instant) { + return instant == null ? null : instant.toEpochMilli(); + } + + private Set scopesFrom(String scopeString) { + if (scopeString == null || scopeString.isBlank()) { + return Set.of(); + } + return Stream.of(scopeString.split(" ")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + private String scopesToString(Set scopes) { + if (scopes == null || scopes.isEmpty()) { + return null; + } + return scopes.stream().sorted().collect(Collectors.joining(" ")); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index 981e812..f6ff4ab 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -26,6 +26,8 @@ public class SchemaMigration implements ApplicationRunner { ensureSessionAttributeUpsertTrigger(); ensureChannelCanvasColumns(); ensureAssetMediaColumns(); + ensureAuthorizedClientTable(); + normalizeAuthorizedClientTimestamps(); } private void ensureSessionAttributeUpsertTrigger() { @@ -101,4 +103,50 @@ public class SchemaMigration implements ApplicationRunner { logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex); } } + + private void ensureAuthorizedClientTable() { + try { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS oauth2_authorized_client ( + client_registration_id VARCHAR(100) NOT NULL, + principal_name VARCHAR(200) NOT NULL, + access_token_type VARCHAR(100), + access_token_value TEXT, + access_token_issued_at INTEGER, + access_token_expires_at INTEGER, + access_token_scopes VARCHAR(1000), + refresh_token_value TEXT, + refresh_token_issued_at INTEGER, + PRIMARY KEY (client_registration_id, principal_name) + ) + """); + logger.info("Ensured oauth2_authorized_client table exists"); + } catch (DataAccessException ex) { + logger.warn("Unable to ensure oauth2_authorized_client table", ex); + } + } + + private void normalizeAuthorizedClientTimestamps() { + normalizeTimestampColumn("access_token_issued_at"); + normalizeTimestampColumn("access_token_expires_at"); + normalizeTimestampColumn("refresh_token_issued_at"); + } + + private void normalizeTimestampColumn(String column) { + try { + int updated = jdbcTemplate.update( + "UPDATE oauth2_authorized_client " + + "SET " + column + " = CASE " + + "WHEN " + column + " LIKE '%-%' THEN CAST(strftime('%s', " + column + ") AS INTEGER) * 1000 " + + "WHEN typeof(" + column + ") = 'text' AND " + column + " GLOB '[0-9]*' THEN CAST(" + column + " AS INTEGER) " + + "WHEN typeof(" + column + ") = 'integer' THEN " + column + " " + + "ELSE " + column + " END " + + "WHERE " + column + " IS NOT NULL"); + if (updated > 0) { + logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column); + } + } catch (DataAccessException ex) { + logger.warn("Unable to normalize oauth2_authorized_client.{} timestamps", column, ex); + } + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java index 5aa7830..b0cc5e6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java @@ -9,6 +9,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -21,7 +22,8 @@ import org.springframework.http.HttpStatus; public class SecurityConfig { @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SecurityFilterChain securityFilterChain(HttpSecurity http, + OAuth2AuthorizedClientRepository authorizedClientRepository) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers( @@ -44,6 +46,7 @@ public class SecurityConfig { .anyRequest().authenticated() ) .oauth2Login(oauth -> oauth + .authorizedClientRepository(authorizedClientRepository) .tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) .userInfoEndpoint(user -> user.userService(twitchOAuth2UserService()))) .logout(logout -> logout.logoutSuccessUrl("/").permitAll()) diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index e91d5b1..d2189dd 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -21,7 +21,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -33,6 +33,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.multipart.MultipartFile; +import jakarta.servlet.http.HttpServletRequest; + import java.util.Collection; import java.io.IOException; import java.util.Comparator; @@ -50,17 +52,20 @@ public class ChannelApiController { private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private final ChannelDirectoryService channelDirectoryService; private final OAuth2AuthorizedClientService authorizedClientService; + private final OAuth2AuthorizedClientRepository authorizedClientRepository; private final TwitchUserLookupService twitchUserLookupService; private final AuthorizationService authorizationService; public ChannelApiController( ChannelDirectoryService channelDirectoryService, OAuth2AuthorizedClientService authorizedClientService, + OAuth2AuthorizedClientRepository authorizedClientRepository, TwitchUserLookupService twitchUserLookupService, AuthorizationService authorizationService ) { this.channelDirectoryService = channelDirectoryService; this.authorizedClientService = authorizedClientService; + this.authorizedClientRepository = authorizedClientRepository; this.twitchUserLookupService = twitchUserLookupService; this.authorizationService = authorizationService; } @@ -82,7 +87,7 @@ public class ChannelApiController { @GetMapping("/admins") public Collection listAdmins(@PathVariable("broadcaster") String broadcaster, OAuth2AuthenticationToken oauthToken, - @RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) { + HttpServletRequest request) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername); @@ -90,7 +95,7 @@ public class ChannelApiController { List admins = channel.getAdmins().stream() .sorted(Comparator.naturalOrder()) .toList(); - authorizedClient = resolveAuthorizedClient(oauthToken, authorizedClient); + OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); String accessToken = Optional.ofNullable(authorizedClient) .map(OAuth2AuthorizedClient::getAccessToken) .map(token -> token.getTokenValue()) @@ -105,12 +110,12 @@ public class ChannelApiController { @GetMapping("/admins/suggestions") public Collection listAdminSuggestions(@PathVariable("broadcaster") String broadcaster, OAuth2AuthenticationToken oauthToken, - @RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) { + HttpServletRequest request) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername); var channel = channelDirectoryService.getOrCreateChannel(broadcaster); - authorizedClient = resolveAuthorizedClient(oauthToken, authorizedClient); + OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); if (authorizedClient == null) { LOG.warn("No authorized Twitch client found for {} while fetching admin suggestions for {}", sessionUsername, broadcaster); @@ -281,13 +286,21 @@ public class ChannelApiController { } private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken, - OAuth2AuthorizedClient authorizedClient) { + OAuth2AuthorizedClient authorizedClient, + HttpServletRequest request) { if (authorizedClient != null) { return authorizedClient; } if (oauthToken == null) { return null; } + OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient( + oauthToken.getAuthorizedClientRegistrationId(), + oauthToken, + request); + if (sessionClient != null) { + return sessionClient; + } return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName()); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a3935ca..f73831e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,9 @@ server: key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12} key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit} key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12} + error: + include-message: never + include-stacktrace: never spring: config: