Fix login

This commit is contained in:
2025-12-04 16:11:56 +01:00
parent b280d5951f
commit 9f9c14c1bb
8 changed files with 412 additions and 1 deletions

View File

@@ -26,6 +26,7 @@ public class SecurityConfig {
) )
.oauth2Login(oauth -> oauth .oauth2Login(oauth -> oauth
.tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) .tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
.userInfoEndpoint(user -> user.userService(twitchOAuth2UserService()))
) )
.logout(logout -> logout.logoutSuccessUrl("/").permitAll()) .logout(logout -> logout.logoutSuccessUrl("/").permitAll())
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); .csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
@@ -35,9 +36,15 @@ public class SecurityConfig {
@Bean @Bean
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> twitchAccessTokenResponseClient() { OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> twitchAccessTokenResponseClient() {
DefaultAuthorizationCodeTokenResponseClient delegate = new DefaultAuthorizationCodeTokenResponseClient(); DefaultAuthorizationCodeTokenResponseClient delegate = new DefaultAuthorizationCodeTokenResponseClient();
delegate.setRequestEntityConverter(new TwitchAuthorizationCodeGrantRequestEntityConverter());
RestTemplate restTemplate = OAuth2RestTemplateFactory.create(); RestTemplate restTemplate = OAuth2RestTemplateFactory.create();
restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler()); restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler());
delegate.setRestOperations(restTemplate); delegate.setRestOperations(restTemplate);
return delegate; return delegate;
} }
@Bean
TwitchOAuth2UserService twitchOAuth2UserService() {
return new TwitchOAuth2UserService();
}
} }

View File

@@ -0,0 +1,56 @@
package com.imgfloat.app.config;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* Ensures Twitch token requests always include {@code client_id} and {@code client_secret} in the
* request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if
* those parameters are absent.
*/
final class TwitchAuthorizationCodeGrantRequestEntityConverter implements
Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
private final Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> delegate =
new OAuth2AuthorizationCodeGrantRequestEntityConverter();
@Override
public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
RequestEntity<?> entity = delegate.convert(request);
if (entity == null || !(entity.getBody() instanceof MultiValueMap<?, ?> existingBody)) {
return entity;
}
ClientRegistration registration = request.getClientRegistration();
MultiValueMap<String, String> body = cloneBody(existingBody);
body.set(OAuth2ParameterNames.CLIENT_ID, registration.getClientId());
if (registration.getClientSecret() != null) {
body.set(OAuth2ParameterNames.CLIENT_SECRET, registration.getClientSecret());
}
return new RequestEntity<>(
body,
entity.getHeaders(),
entity.getMethod() == null ? HttpMethod.POST : entity.getMethod(),
entity.getUrl() == null ? URI.create(registration.getProviderDetails().getTokenUri()) : entity.getUrl());
}
private MultiValueMap<String, String> cloneBody(MultiValueMap<?, ?> existingBody) {
MultiValueMap<String, String> copy = new LinkedMultiValueMap<>();
existingBody.forEach((key, value) ->
copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
return copy;
}
}

View File

@@ -0,0 +1,84 @@
package com.imgfloat.app.config;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
/**
* Sanitizes Twitch client registration values (especially whitespace or quoted secrets) before
* wiring them into Spring Security.
*/
@Configuration
@EnableConfigurationProperties(OAuth2ClientProperties.class)
class TwitchClientRegistrationConfig {
private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class);
@Bean
@Primary
ClientRegistrationRepository twitchClientRegistrationRepository(OAuth2ClientProperties properties) {
List<ClientRegistration> registrations = new ArrayList<>();
for (Map.Entry<String, OAuth2ClientProperties.Registration> entry : properties.getRegistration().entrySet()) {
String registrationId = entry.getKey();
OAuth2ClientProperties.Registration registration = entry.getValue();
String providerId = registration.getProvider() != null ? registration.getProvider() : registrationId;
OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId);
if (provider == null) {
throw new IllegalStateException(
"Missing OAuth2 provider configuration for registration '" + registrationId + "'.");
}
if (!"twitch".equals(registrationId)) {
LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId);
continue;
}
registrations.add(buildTwitchRegistration(registrationId, registration, provider));
}
return new InMemoryClientRegistrationRepository(registrations);
}
private ClientRegistration buildTwitchRegistration(
String registrationId,
OAuth2ClientProperties.Registration registration,
OAuth2ClientProperties.Provider provider) {
String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID");
String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET");
return ClientRegistration.withRegistrationId(registrationId)
.clientName(registration.getClientName())
.clientId(clientId)
.clientSecret(clientSecret)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType()))
.redirectUri(registration.getRedirectUri())
.scope(registration.getScope())
.authorizationUri(provider.getAuthorizationUri())
.tokenUri(provider.getTokenUri())
.userInfoUri(provider.getUserInfoUri())
.userNameAttributeName(provider.getUserNameAttribute())
.build();
}
private String sanitize(String value, String name) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
String unquoted = trimmed.substring(1, trimmed.length() - 1).trim();
LOG.info("Sanitizing {} by stripping surrounding quotes.", name);
return unquoted;
}
return trimmed;
}
}

View File

@@ -0,0 +1,106 @@
package com.imgfloat.app.config;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.client.RestTemplate;
/**
* Adds the Twitch required "Client-ID" header to user info requests while preserving the default
* Spring behavior for mapping the response into an {@link OAuth2User}.
*/
class TwitchOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final Function<OAuth2UserRequest, RestTemplate> restTemplateFactory;
TwitchOAuth2UserService() {
this(TwitchOAuth2UserService::createRestTemplate);
}
TwitchOAuth2UserService(Function<OAuth2UserRequest, RestTemplate> restTemplateFactory) {
this.restTemplateFactory = restTemplateFactory;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
delegate.setRestOperations(restTemplateFactory.apply(userRequest));
OAuth2User delegateUser = delegate.loadUser(twitchUserRequest(userRequest));
Map<String, Object> twitchUser = unwrapUserAttributes(delegateUser.getAttributes());
return new DefaultOAuth2User(delegateUser.getAuthorities(), twitchUser, "login");
}
private OAuth2UserRequest twitchUserRequest(OAuth2UserRequest userRequest) {
return new OAuth2UserRequest(
twitchUserRegistration(userRequest),
userRequest.getAccessToken(),
userRequest.getAdditionalParameters());
}
private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) {
ClientRegistration registration = userRequest.getClientRegistration();
return ClientRegistration.withClientRegistration(registration)
// The Twitch response nests user details under a "data" array, so accept that
// shape for the initial parsing step.
.userNameAttributeName("data")
.build();
}
@SuppressWarnings("unchecked")
private Map<String, Object> unwrapUserAttributes(Map<String, Object> responseAttributes) {
Object data = responseAttributes.get("data");
if (!(data instanceof List<?> list) || list.isEmpty() || !(list.get(0) instanceof Map<?, ?> first)) {
throw invalidUserInfo("Missing Twitch user array in user info response");
}
Map<String, Object> twitchUser = new HashMap<>();
first.forEach((key, value) -> twitchUser.put(String.valueOf(key), value));
Object login = twitchUser.get("login");
if (!(login instanceof String loginValue) || loginValue.isBlank()) {
throw invalidUserInfo("Missing Twitch login in user info response");
}
return twitchUser;
}
private OAuth2AuthenticationException invalidUserInfo(String message) {
return new OAuth2AuthenticationException(new OAuth2Error("invalid_user_info_response", message, null));
}
static RestTemplate createRestTemplate(OAuth2UserRequest userRequest) {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(createRequestFactory(restTemplate.getRequestFactory()));
List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>(restTemplate.getInterceptors());
interceptors.add((request, body, execution) -> {
request.getHeaders().add("Client-ID", userRequest.getClientRegistration().getClientId());
return execution.execute(request, body);
});
restTemplate.setInterceptors(interceptors);
restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler());
return restTemplate;
}
private static ClientHttpRequestFactory createRequestFactory(ClientHttpRequestFactory existing) {
if (existing instanceof SimpleClientHttpRequestFactory simple) {
simple.setConnectTimeout(30_000);
simple.setReadTimeout(30_000);
return simple;
}
return existing;
}
}

View File

@@ -20,6 +20,7 @@ spring:
twitch: twitch:
client-id: ${TWITCH_CLIENT_ID} client-id: ${TWITCH_CLIENT_ID}
client-secret: ${TWITCH_CLIENT_SECRET} client-secret: ${TWITCH_CLIENT_SECRET}
client-authentication-method: client_secret_post
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}" redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
authorization-grant-type: authorization_code authorization-grant-type: authorization_code
scope: ["user:read:email"] scope: ["user:read:email"]
@@ -28,7 +29,7 @@ spring:
authorization-uri: https://id.twitch.tv/oauth2/authorize authorization-uri: https://id.twitch.tv/oauth2/authorize
token-uri: https://id.twitch.tv/oauth2/token token-uri: https://id.twitch.tv/oauth2/token
user-info-uri: https://api.twitch.tv/helix/users user-info-uri: https://api.twitch.tv/helix/users
user-name-attribute: preferred_username user-name-attribute: login
management: management:
endpoints: endpoints:

View File

@@ -37,4 +37,28 @@ class TwitchEnvironmentValidationTest {
} }
} }
} }
@Test
void stripsQuotesAndWhitespaceFromCredentials() {
ConfigurableApplicationContext context = null;
try {
context = new SpringApplicationBuilder(ImgfloatApplication.class)
.properties(
"server.port=0",
"TWITCH_CLIENT_ID=\" quoted-id \"",
"TWITCH_CLIENT_SECRET=' quoted-secret '"
)
.run();
var clientRegistrationRepository = context.getBean(org.springframework.security.oauth2.client.registration.ClientRegistrationRepository.class);
var twitch = clientRegistrationRepository.findByRegistrationId("twitch");
org.assertj.core.api.Assertions.assertThat(twitch.getClientId()).isEqualTo("quoted-id");
org.assertj.core.api.Assertions.assertThat(twitch.getClientSecret()).isEqualTo("quoted-secret");
} finally {
if (context != null) {
context.close();
}
}
}
} }

View File

@@ -0,0 +1,57 @@
package com.imgfloat.app.config;
import org.junit.jupiter.api.Test;
import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.MultiValueMap;
import static org.assertj.core.api.Assertions.assertThat;
class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
@Test
void addsClientIdAndSecretToTokenRequestBody() {
ClientRegistration registration = ClientRegistration.withRegistrationId("twitch")
.clientId("twitch-id")
.clientSecret("twitch-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://example.com/redirect")
.scope("user:read:email")
.authorizationUri("https://id.twitch.tv/oauth2/authorize")
.tokenUri("https://id.twitch.tv/oauth2/token")
.userInfoUri("https://api.twitch.tv/helix/users")
.userNameAttributeName("preferred_username")
.build();
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
.clientId(registration.getClientId())
.redirectUri(registration.getRedirectUri())
.state("state")
.build();
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code")
.redirectUri(registration.getRedirectUri())
.state("state")
.build();
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse);
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(registration, exchange);
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
RequestEntity<?> requestEntity = converter.convert(grantRequest);
MultiValueMap<String, String> body = (MultiValueMap<String, String>) requestEntity.getBody();
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id");
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret");
}
}

View File

@@ -0,0 +1,76 @@
package com.imgfloat.app.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.time.Instant;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
class TwitchOAuth2UserServiceTest {
@Test
void unwrapsTwitchUserAndAddsClientIdHeaderToUserInfoRequest() {
ClientRegistration registration = twitchRegistrationBuilder()
.clientId("client-123")
.clientSecret("secret")
.build();
OAuth2UserRequest userRequest = userRequest(registration);
RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest);
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
TwitchOAuth2UserService service = new TwitchOAuth2UserService(ignored -> restTemplate);
server.expect(requestTo("https://api.twitch.tv/helix/users"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Client-ID", "client-123"))
.andRespond(withSuccess(
"{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}",
MediaType.APPLICATION_JSON));
OAuth2User user = service.loadUser(userRequest);
assertThat(user.getName()).isEqualTo("demo");
assertThat(user.getAttributes())
.containsEntry("id", "42")
.containsEntry("display_name", "Demo");
server.verify();
}
private OAuth2UserRequest userRequest(ClientRegistration registration) {
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token",
Instant.now(),
Instant.now().plusSeconds(60),
Set.of("user:read:email"));
return new OAuth2UserRequest(registration, accessToken);
}
private ClientRegistration.Builder twitchRegistrationBuilder() {
return ClientRegistration.withRegistrationId("twitch")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.clientName("Twitch")
.redirectUri("https://example.com/login/oauth2/code/twitch")
.authorizationUri("https://id.twitch.tv/oauth2/authorize")
.tokenUri("https://id.twitch.tv/oauth2/token")
.userInfoUri("https://api.twitch.tv/helix/users")
.userNameAttributeName("login");
}
}