mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Fix login
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user