Retain twitch tokens

This commit is contained in:
2026-01-01 14:37:36 +01:00
parent 9cdebf076c
commit 89874a4bfb
6 changed files with 260 additions and 7 deletions

View File

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

View File

@@ -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<OAuth2AuthorizedClient> 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 extends OAuth2AuthorizedClient> 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<String> 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<String> scopes) {
if (scopes == null || scopes.isEmpty()) {
return null;
}
return scopes.stream().sorted().collect(Collectors.joining(" "));
}
}

View File

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

View File

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

View File

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

View File

@@ -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: