mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Retain twitch tokens
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user