Compare commits
5 Commits
1d81e3887a
...
709dc23250
Author | SHA1 | Date | |
---|---|---|---|
709dc23250 | |||
1a2a84f674 | |||
857d59d391 | |||
c421125eb4 | |||
71862afb55 |
Dockerfile
src
main
java/se/su/dsv/oauth2
resources/templates
test/java/se/su/dsv/oauth2
42
Dockerfile
42
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM eclipse-temurin:23 AS build
|
||||
FROM eclipse-temurin:24 AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@ -25,22 +25,52 @@ COPY src src
|
||||
|
||||
RUN ./mvnw compile
|
||||
|
||||
FROM debian:stable-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
FROM eclipse-temurin:24 AS training
|
||||
|
||||
WORKDIR /build
|
||||
COPY --from=build /build/jre jre
|
||||
COPY --from=build /build/lib lib
|
||||
COPY --from=build /build/classpath classpath
|
||||
COPY --from=build /build/target/classes classes
|
||||
|
||||
# Adds the output of Maven compilation to output
|
||||
RUN echo ":classes" >> classpath
|
||||
# There can be no directories on the classpath when training
|
||||
RUN cd classes && jar -cf /build/app.jar *
|
||||
|
||||
# Adds the new jar to the classpath
|
||||
RUN echo ":app.jar" >> classpath
|
||||
|
||||
RUN [ "./jre/bin/java" \
|
||||
, "-cp", "@classpath" \
|
||||
, "-XX:AOTMode=record" \
|
||||
, "-XX:AOTConfiguration=app.aotconf" \
|
||||
, "se.su.dsv.oauth2.Training" \
|
||||
, "--spring.profiles.active=dev,embedded" \
|
||||
]
|
||||
|
||||
RUN [ "./jre/bin/java" \
|
||||
, "-cp", "@classpath" \
|
||||
, "-XX:AOTMode=create" \
|
||||
, "-XX:AOTConfiguration=app.aotconf" \
|
||||
, "-XX:AOTCache=app.aot" \
|
||||
, "se.su.dsv.oauth2.Training" \
|
||||
, "--spring.profiles.active=dev,embedded" \
|
||||
]
|
||||
|
||||
FROM debian:stable-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=training /build/jre jre
|
||||
COPY --from=training /build/lib lib
|
||||
COPY --from=training /build/classpath classpath
|
||||
COPY --from=training /build/app.jar app.jar
|
||||
COPY --from=training /build/app.aot app.aot
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD [ "./jre/bin/java" \
|
||||
, "-cp", "@classpath" \
|
||||
, "-XX:AOTCache=app.aot" \
|
||||
, "se.su.dsv.oauth2.AuthorizationServer" \
|
||||
, "--spring.profiles.active=dev,embedded" \
|
||||
]
|
||||
|
@ -9,7 +9,6 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
@ -24,10 +23,8 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter;
|
||||
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
|
||||
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||
import se.su.dsv.oauth2.shibboleth.Entitlement;
|
||||
import se.su.dsv.oauth2.shibboleth.ShibbolethAuthenticationDetailsSource;
|
||||
import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer;
|
||||
import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator;
|
||||
import se.su.dsv.oauth2.staging.StagingSecurityConfigurer;
|
||||
@ -140,30 +137,11 @@ public class AuthorizationServer extends SpringBootServletInitializer {
|
||||
.accessDeniedPage("/forbidden")
|
||||
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")));
|
||||
|
||||
http.jee(jee -> jee
|
||||
.withObjectPostProcessor(makeShibbolethAware()));
|
||||
http.with(new ShibbolethConfigurer(), Customizer.withDefaults());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private static ObjectPostProcessor<J2eePreAuthenticatedProcessingFilter> makeShibbolethAware()
|
||||
{
|
||||
return new ObjectPostProcessor<>() {
|
||||
@Override
|
||||
public <O extends J2eePreAuthenticatedProcessingFilter> O postProcess(final O object) {
|
||||
// Using a custom authentication details source to extract the Shibboleth attributes
|
||||
// and convert them to the relevant Spring Security objects.
|
||||
object.setAuthenticationDetailsSource(new ShibbolethAuthenticationDetailsSource());
|
||||
|
||||
// Prevent session creation
|
||||
// It can cause conflicts when running on the same host as an embedded docker container
|
||||
// as it overwrites the session cookie (it does not factor in port)
|
||||
object.setSecurityContextRepository(new RequestAttributeSecurityContextRepository());
|
||||
return object;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
|
||||
return new ShibbolethTokenPopulator();
|
||||
|
@ -1,8 +1,11 @@
|
||||
package se.su.dsv.oauth2;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.filter.ForwardedHeaderFilter;
|
||||
import se.su.dsv.oauth2.admin.repository.ClientRepository;
|
||||
import se.su.dsv.oauth2.admin.repository.ClientRow;
|
||||
|
||||
@ -15,6 +18,16 @@ import java.util.Optional;
|
||||
@Configuration
|
||||
@Profile("embedded")
|
||||
public class EmbeddedConfiguration {
|
||||
/**
|
||||
* This filter takes the `X-Forwarded-*` headers and updates the request to reflect the original HTTP request.
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
|
||||
var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter());
|
||||
filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
return filterRegistrationBean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ClientRepository clientRepository() {
|
||||
ArrayList<ClientRow> clients = new ArrayList<>();
|
||||
|
15
src/main/java/se/su/dsv/oauth2/Training.java
Normal file
15
src/main/java/se/su/dsv/oauth2/Training.java
Normal file
@ -0,0 +1,15 @@
|
||||
package se.su.dsv.oauth2;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
public class Training {
|
||||
// Used by the Docker build to do a training run to speed up the startup time of the application.
|
||||
// See https://openjdk.org/jeps/483
|
||||
// Training is very basic and just boots the application and immediately shuts it down.
|
||||
// A better training run would involve going through a full OAuth 2 flow to load all the classes.
|
||||
public static void main(String[] args) {
|
||||
ConfigurableApplicationContext application = SpringApplication.run(AuthorizationServer.class, args);
|
||||
application.close();
|
||||
}
|
||||
}
|
29
src/main/java/se/su/dsv/oauth2/shibboleth/ShibbolethAuthenticatedProcessingFilter.java
Normal file
29
src/main/java/se/su/dsv/oauth2/shibboleth/ShibbolethAuthenticatedProcessingFilter.java
Normal file
@ -0,0 +1,29 @@
|
||||
package se.su.dsv.oauth2.shibboleth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/// Can't use the built-in [J2eePreAuthenticatedProcessingFilter] because Tomcat
|
||||
/// unconditionally provides a [HttpServletRequest#getUserPrincipal()] with a
|
||||
/// blank [Principal#getName()] that causes the authentication process to fail.
|
||||
class ShibbolethAuthenticatedProcessingFilter extends J2eePreAuthenticatedProcessingFilter {
|
||||
@Override
|
||||
protected Object getPreAuthenticatedPrincipal(final HttpServletRequest httpRequest) {
|
||||
Principal userPrincipal = httpRequest.getUserPrincipal();
|
||||
if (userPrincipal == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tomcat provides a blank Principal name when the user is not authenticated.
|
||||
String principalName = userPrincipal.getName();
|
||||
if (principalName == null || principalName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug(LogMessage.format("PreAuthenticated J2EE principal: %s", principalName));
|
||||
return principalName;
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ public class ShibbolethConfigurer extends AbstractHttpConfigurer<ShibbolethConfi
|
||||
|
||||
@Override
|
||||
public void configure(final HttpSecurity http) {
|
||||
J2eePreAuthenticatedProcessingFilter filter = new J2eePreAuthenticatedProcessingFilter();
|
||||
J2eePreAuthenticatedProcessingFilter filter = new ShibbolethAuthenticatedProcessingFilter();
|
||||
filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
|
||||
filter.setAuthenticationDetailsSource(new ShibbolethAuthenticationDetailsSource());
|
||||
filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
|
||||
|
@ -9,11 +9,14 @@ import jakarta.servlet.http.HttpFilter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter;
|
||||
import org.springframework.security.web.DefaultRedirectStrategy;
|
||||
@ -72,7 +75,11 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
|
||||
}
|
||||
|
||||
if (loggedInUser.getAuthorities().contains(new Entitlement(developerEntitlement))) {
|
||||
proceedWithDeveloperAuthorization(request, response, loggedInUser);
|
||||
try {
|
||||
proceedWithDeveloperAuthorization(request, response, loggedInUser);
|
||||
} catch (OAuth2AuthorizationCodeRequestAuthenticationException exception) {
|
||||
sendAuthorizationError(request, response, exception);
|
||||
}
|
||||
} else {
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
@ -85,6 +92,10 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
|
||||
throws IOException
|
||||
{
|
||||
if (Objects.equals(request.getMethod(), "GET")) {
|
||||
// Validate authorization request
|
||||
// This will throw if the request is not valid
|
||||
authenticationConverter.convert(request);
|
||||
|
||||
String authorizationUrl = getAuthorizationUrl(request);
|
||||
JteModel view = templates.authorize(authorizationUrl, loggedInUser.getName(), (ShibbolethAuthenticationDetails) loggedInUser.getDetails());
|
||||
respondWithTemplate(response, view);
|
||||
@ -193,4 +204,40 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
|
||||
String redirectUri = uriBuilder.build(true).toUriString();
|
||||
this.redirectStrategy.sendRedirect(request, response, redirectUri);
|
||||
}
|
||||
|
||||
private void sendAuthorizationError(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthorizationCodeRequestAuthenticationException authorizationCodeRequestAuthenticationException)
|
||||
throws IOException
|
||||
{
|
||||
OAuth2Error error = authorizationCodeRequestAuthenticationException.getError();
|
||||
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authorizationCodeRequestAuthenticationException
|
||||
.getAuthorizationCodeRequestAuthentication();
|
||||
|
||||
if (authorizationCodeRequestAuthentication == null
|
||||
|| !StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) {
|
||||
response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder
|
||||
.fromUriString(authorizationCodeRequestAuthentication.getRedirectUri())
|
||||
.queryParam(OAuth2ParameterNames.ERROR, error.getErrorCode());
|
||||
if (StringUtils.hasText(error.getDescription())) {
|
||||
uriBuilder.queryParam(OAuth2ParameterNames.ERROR_DESCRIPTION,
|
||||
UriUtils.encode(error.getDescription(), StandardCharsets.UTF_8));
|
||||
}
|
||||
if (StringUtils.hasText(error.getUri())) {
|
||||
uriBuilder.queryParam(OAuth2ParameterNames.ERROR_URI,
|
||||
UriUtils.encode(error.getUri(), StandardCharsets.UTF_8));
|
||||
}
|
||||
if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) {
|
||||
uriBuilder.queryParam(OAuth2ParameterNames.STATE,
|
||||
UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8));
|
||||
}
|
||||
// build(true) -> Components are explicitly encoded
|
||||
String redirectUri = uriBuilder.build(true).toUriString();
|
||||
this.redirectStrategy.sendRedirect(request, response, redirectUri);
|
||||
}
|
||||
}
|
||||
|
8
src/main/resources/templates/error.jte
Normal file
8
src/main/resources/templates/error.jte
Normal file
@ -0,0 +1,8 @@
|
||||
@param Integer status
|
||||
@param String error
|
||||
@param String message
|
||||
|
||||
@template.base(title = message, content = @`
|
||||
<h1>${status} ${error}</h1>
|
||||
<p>${message}</p>
|
||||
`)
|
@ -6,8 +6,15 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static se.su.dsv.oauth2.ShibbolethRequestProcessor.remoteUser;
|
||||
import static se.su.dsv.oauth2.TestRegisteredClientConfiguration.CLIENT_ID;
|
||||
import static se.su.dsv.oauth2.TestRegisteredClientConfiguration.REDIRECT_URI;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = TestRegisteredClientConfiguration.class,
|
||||
@ -155,4 +162,94 @@ public class StagingProfileTest extends AbstractMetadataCodeFlowTest {
|
||||
assertTrue(claims.getStringListClaim("entitlements").contains(customEntitlement),
|
||||
"Does not contain custom entitlement");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void correctly_handles_missing_response_type_parameter() throws Exception {
|
||||
mockMvc.perform(post(getAuthorizationEndpoint())
|
||||
.with(remoteUser("developer")
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("client_id", CLIENT_ID)
|
||||
.queryParam("redirect_uri", REDIRECT_URI)
|
||||
.formField("principal", "developer"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(status().reason(containsString("response_type")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redirects_back_to_client_if_there_are_errors_but_valid_redirect_uri() throws Exception {
|
||||
mockMvc.perform(post(getAuthorizationEndpoint())
|
||||
.with(remoteUser("developer")
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("client_id", CLIENT_ID)
|
||||
.queryParam("redirect_uri", REDIRECT_URI)
|
||||
.queryParam("scope", "invalid")
|
||||
.formField("principal", "developer"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(result -> {
|
||||
String redirectedUrl = result.getResponse().getRedirectedUrl();
|
||||
assertThat(redirectedUrl, containsString("error=invalid_scope"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void does_not_redirect_with_invalid_client_id() throws Exception {
|
||||
mockMvc.perform(post(getAuthorizationEndpoint())
|
||||
.with(remoteUser("developer")
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("client_id", "invalid-client-id")
|
||||
.queryParam("redirect_uri", REDIRECT_URI)
|
||||
.formField("principal", "developer"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(status().reason(containsString("client_id")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void maintains_state_during_error_redirect() throws Exception {
|
||||
String state = "state123";
|
||||
|
||||
mockMvc.perform(post(getAuthorizationEndpoint())
|
||||
.with(remoteUser("developer")
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("client_id", CLIENT_ID)
|
||||
.queryParam("redirect_uri", REDIRECT_URI)
|
||||
.queryParam("state", state)
|
||||
.queryParam("scope", "invalid")
|
||||
.formField("principal", "developer"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(result -> {
|
||||
String redirectedUrl = result.getResponse().getRedirectedUrl();
|
||||
assertThat(redirectedUrl, containsString("error=invalid_scope"));
|
||||
assertThat(redirectedUrl, containsString("state=" + state));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fails_immediately_with_invalid_request_parameters() throws Exception {
|
||||
mockMvc.perform(get(getAuthorizationEndpoint())
|
||||
.with(remoteUser("developer")
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("client_id", CLIENT_ID)
|
||||
.queryParam("redirect_uri", REDIRECT_URI)
|
||||
.queryParam("scope", "openid")
|
||||
.queryParam("scope", "profile"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(status().reason(containsString("scope")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shows_custom_authorization_form_for_valid_requests() throws Exception {
|
||||
mockMvc.perform(get(getAuthorizationEndpoint())
|
||||
.with(remoteUser("developer")
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("client_id", CLIENT_ID)
|
||||
.queryParam("redirect_uri", REDIRECT_URI)
|
||||
.queryParam("scope", "openid profile"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("<form")));
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user