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
.tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
.userInfoEndpoint(user -> user.userService(twitchOAuth2UserService()))
)
.logout(logout -> logout.logoutSuccessUrl("/").permitAll())
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
@@ -35,9 +36,15 @@ public class SecurityConfig {
@Bean
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> twitchAccessTokenResponseClient() {
DefaultAuthorizationCodeTokenResponseClient delegate = new DefaultAuthorizationCodeTokenResponseClient();
delegate.setRequestEntityConverter(new TwitchAuthorizationCodeGrantRequestEntityConverter());
RestTemplate restTemplate = OAuth2RestTemplateFactory.create();
restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler());
delegate.setRestOperations(restTemplate);
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:
client-id: ${TWITCH_CLIENT_ID}
client-secret: ${TWITCH_CLIENT_SECRET}
client-authentication-method: client_secret_post
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
authorization-grant-type: authorization_code
scope: ["user:read:email"]
@@ -28,7 +29,7 @@ spring:
authorization-uri: https://id.twitch.tv/oauth2/authorize
token-uri: https://id.twitch.tv/oauth2/token
user-info-uri: https://api.twitch.tv/helix/users
user-name-attribute: preferred_username
user-name-attribute: login
management:
endpoints: