Compare commits
4 Commits
e826ce523d
...
2ebf8c649d
Author | SHA1 | Date | |
---|---|---|---|
2ebf8c649d | |||
f1fbd306e2 | |||
119e27f5da | |||
efcfddaa70 |
src
main
test/java/se/su/dsv/oauth2
@ -18,6 +18,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
|
||||
@ -31,11 +32,12 @@ import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||
import se.su.dsv.oauth2.shibboleth.Entitlement;
|
||||
import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer;
|
||||
import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator;
|
||||
import se.su.dsv.oauth2.staging.SkipConsentForDevelopers;
|
||||
import se.su.dsv.oauth2.staging.StagingSecurityConfigurer;
|
||||
import se.su.dsv.oauth2.web.client.DeveloperAccessCheck;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@SpringBootApplication(proxyBeanMethods = false)
|
||||
@EnableWebSecurity
|
||||
@ -75,7 +77,9 @@ public class AuthorizationServer extends SpringBootServletInitializer {
|
||||
/// the session and allow the flow to start.
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain oauth2Endpoints(HttpSecurity http, Optional<HttpSecurityCustomizer> customizer)
|
||||
public SecurityFilterChain oauth2Endpoints(
|
||||
HttpSecurity http,
|
||||
List<HttpSecurityCustomizer> customizer)
|
||||
throws Exception
|
||||
{
|
||||
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
|
||||
@ -100,7 +104,7 @@ public class AuthorizationServer extends SpringBootServletInitializer {
|
||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
|
||||
));
|
||||
|
||||
customizer.ifPresent(c -> {
|
||||
customizer.forEach(c -> {
|
||||
try {
|
||||
c.customize(http);
|
||||
} catch (Exception e) {
|
||||
@ -130,6 +134,27 @@ public class AuthorizationServer extends SpringBootServletInitializer {
|
||||
};
|
||||
}
|
||||
|
||||
/// Disable consent in staging. With the custom authorization in place, the current user is always the developer
|
||||
/// while the authorization request token may contain a custom principal. When Spring Authorization Server attempts
|
||||
/// to validate the submitted consent, it checks that the current user is the same as the authorization request
|
||||
/// token - which it is not. The easiest solution is to disable consent in staging for developers.
|
||||
@Bean
|
||||
@Profile("staging")
|
||||
public HttpSecurityCustomizer disableConsentInStaging(
|
||||
DeveloperAccessCheck developerAccessCheck)
|
||||
{
|
||||
ObjectPostProcessor<OAuth2AuthorizationCodeRequestAuthenticationProvider> postprocessor = new ObjectPostProcessor<>() {
|
||||
@Override
|
||||
public <O extends OAuth2AuthorizationCodeRequestAuthenticationProvider> O postProcess(final O object) {
|
||||
object.setAuthorizationConsentRequired(new SkipConsentForDevelopers(developerAccessCheck));
|
||||
return object;
|
||||
}
|
||||
};
|
||||
return http -> http
|
||||
.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
|
||||
.withObjectPostProcessor(postprocessor);
|
||||
}
|
||||
|
||||
public interface HttpSecurityCustomizer {
|
||||
void customize(HttpSecurity http) throws Exception;
|
||||
}
|
||||
@ -205,4 +230,5 @@ public class AuthorizationServer extends SpringBootServletInitializer {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -18,10 +18,12 @@ 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.authentication.OAuth2AuthorizationConsentAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter;
|
||||
import org.springframework.security.web.DefaultRedirectStrategy;
|
||||
import org.springframework.security.web.RedirectStrategy;
|
||||
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
|
||||
import org.springframework.security.web.util.RedirectUrlBuilder;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
@ -117,15 +119,21 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
|
||||
Authentication authenticatedPrincipal = authenticationManager.authenticate(principal);
|
||||
|
||||
Authentication normalCodeRequest = authenticationConverter.convert(withGetMethod(request));
|
||||
Authentication codeRequest = overridePrincipal(authenticatedPrincipal, (OAuth2AuthorizationCodeRequestAuthenticationToken) normalCodeRequest);
|
||||
OAuth2AuthorizationCodeRequestAuthenticationToken codeRequestAuthenticationToken = (OAuth2AuthorizationCodeRequestAuthenticationToken) normalCodeRequest;
|
||||
Authentication codeRequest = overridePrincipal(authenticatedPrincipal, codeRequestAuthenticationToken);
|
||||
Authentication authenticatedCodeRequest = authenticationManager.authenticate(codeRequest);
|
||||
|
||||
if (!authenticatedCodeRequest.isAuthenticated()) {
|
||||
String authorizationUrl = getAuthorizationUrl(request);
|
||||
respondWithTemplate(response, templates.authorize(authorizationUrl, loggedInUser.getName(), (ShibbolethAuthenticationDetails) authenticatedPrincipal));
|
||||
} else {
|
||||
sendAuthorizationResponse(request, response, authenticatedCodeRequest);
|
||||
|
||||
if (authenticatedCodeRequest instanceof OAuth2AuthorizationCodeRequestAuthenticationToken authenticatedCodeRequestAuthenticationToken) {
|
||||
sendAuthorizationResponse(request, response, authenticatedCodeRequestAuthenticationToken);
|
||||
} else if (authenticatedCodeRequest instanceof OAuth2AuthorizationConsentAuthenticationToken consent) {
|
||||
sendConsentResponse(request, response, codeRequestAuthenticationToken, consent);
|
||||
} else {
|
||||
response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Invalid authentication token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,10 +196,12 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
|
||||
output.writeTo(response.getOutputStream());
|
||||
}
|
||||
|
||||
private void sendAuthorizationResponse(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException {
|
||||
|
||||
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
|
||||
private void sendAuthorizationResponse(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication)
|
||||
throws IOException
|
||||
{
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder
|
||||
.fromUriString(authorizationCodeRequestAuthentication.getRedirectUri())
|
||||
.queryParam(OAuth2ParameterNames.CODE,
|
||||
@ -240,4 +250,31 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
|
||||
String redirectUri = uriBuilder.build(true).toUriString();
|
||||
this.redirectStrategy.sendRedirect(request, response, redirectUri);
|
||||
}
|
||||
|
||||
private void sendConsentResponse(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthorizationCodeRequestAuthenticationToken codeRequestAuthenticationToken,
|
||||
OAuth2AuthorizationConsentAuthenticationToken consent)
|
||||
throws IOException
|
||||
{
|
||||
Set<String> requestedScopes = codeRequestAuthenticationToken.getScopes();
|
||||
String consentUri = resolveConsentUri(request, "/oauth2/consent");
|
||||
String redirectUri = UriComponentsBuilder.fromUriString(consentUri)
|
||||
.queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes))
|
||||
.queryParam(OAuth2ParameterNames.CLIENT_ID, consent.getClientId())
|
||||
.queryParam(OAuth2ParameterNames.STATE, consent.getState())
|
||||
.toUriString();
|
||||
this.redirectStrategy.sendRedirect(request, response, redirectUri);
|
||||
}
|
||||
|
||||
private String resolveConsentUri(HttpServletRequest request, String consentPage) {
|
||||
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
|
||||
urlBuilder.setScheme(request.getScheme());
|
||||
urlBuilder.setServerName(request.getServerName());
|
||||
urlBuilder.setPort(request.getServerPort());
|
||||
urlBuilder.setContextPath(request.getContextPath());
|
||||
urlBuilder.setPathInfo(consentPage);
|
||||
return urlBuilder.getUrl();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
package se.su.dsv.oauth2.staging;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext;
|
||||
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class SkipConsentForDevelopers implements Predicate<OAuth2AuthorizationCodeRequestAuthenticationContext> {
|
||||
private final Predicate<Authentication> developerAccessCheck;
|
||||
|
||||
public SkipConsentForDevelopers(final Predicate<Authentication> developerAccessCheck) {
|
||||
this.developerAccessCheck = developerAccessCheck;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean test(final OAuth2AuthorizationCodeRequestAuthenticationContext context) {
|
||||
Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (developerAccessCheck.test(currentUser)) {
|
||||
return false;
|
||||
} else {
|
||||
// TODO maybe check if consent is already given? useful to always request consent during testing?
|
||||
return context.getRegisteredClient().getClientSettings().isRequireAuthorizationConsent();
|
||||
}
|
||||
}
|
||||
}
|
@ -81,6 +81,7 @@ public class ConsentController {
|
||||
}
|
||||
return Arrays.stream(scopeString.split(" "))
|
||||
.filter(s -> !s.isBlank())
|
||||
.filter(scope -> !scope.equals("openid"))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
|
@ -30,10 +30,7 @@
|
||||
</li>
|
||||
@for (var scope : scopes)
|
||||
<li class="list-group-item">
|
||||
<input class="form-check-input" type="checkbox" name="scope" value="${scope}" id="scope_${scope}" checked>
|
||||
<label class="form-check-label" for="scope_${scope}">
|
||||
${scope}
|
||||
</label>
|
||||
@template.consent_scope(scope = scope)
|
||||
</li>
|
||||
@endfor
|
||||
</ul>
|
||||
|
31
src/main/resources/templates/consent_scope.jte
Normal file
31
src/main/resources/templates/consent_scope.jte
Normal file
@ -0,0 +1,31 @@
|
||||
@import java.util.Objects
|
||||
|
||||
@param String scope
|
||||
|
||||
<label class="d-flex gap-3">
|
||||
<input class="form-check-input flex-shrink-0" type="checkbox" name="scope" value="${scope}" id="scope_${scope}" checked aria-label="${scope}">
|
||||
@if (Objects.equals("profile", scope))
|
||||
<dl>
|
||||
<dt>Given name</dt>
|
||||
<dd>...</dd>
|
||||
|
||||
<dt>Family name</dt>
|
||||
<dd>...</dd>
|
||||
|
||||
<dt>Display name</dt>
|
||||
<dd>...</dd>
|
||||
</dl>
|
||||
@elseif (Objects.equals("email", scope))
|
||||
<dl>
|
||||
<dt>E-mail address</dt>
|
||||
<dd>...</dd>
|
||||
</dl>
|
||||
@elseif (Objects.equals("offline_access", scope))
|
||||
<div>Maintain access after you leave the application</div>
|
||||
@else
|
||||
<div>${scope}</div>
|
||||
@endif
|
||||
<div class="ms-auto">
|
||||
<small class="text-muted">scope: ${scope}</small>
|
||||
</div>
|
||||
</label>
|
@ -0,0 +1,104 @@
|
||||
package se.su.dsv.oauth2;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import org.testcontainers.containers.MariaDBContainer;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
|
||||
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;
|
||||
|
||||
@SpringBootTest(
|
||||
properties = {
|
||||
"se.su.dsv.oauth2.developer-entitlement=" + ConsentFlowCustomAuthorizationTest.DEVELOPER_ENTITLEMENT
|
||||
},
|
||||
classes = ConsentFlowTest.TestConfig.class)
|
||||
@ActiveProfiles("staging")
|
||||
public class ConsentFlowCustomAuthorizationTest extends AbstractMetadataTest {
|
||||
public static final String DEVELOPER_ENTITLEMENT = "developer";
|
||||
|
||||
@ServiceConnection
|
||||
static MariaDBContainer<?> mariaDBContainer = new MariaDBContainer<>("mariadb:10.11");
|
||||
|
||||
@Test
|
||||
public void consent_is_disabled_for_developers() throws Exception {
|
||||
String developerPrincipal = "master-of-the-universe";
|
||||
String customPrincipal = "lowly-regular-user";
|
||||
|
||||
MvcResult authorizationResult = mockMvc.perform(post(getAuthorizationEndpoint())
|
||||
.with(remoteUser(developerPrincipal)
|
||||
.entitlement(DEVELOPER_ENTITLEMENT))
|
||||
.queryParam("client_id", ConsentFlowTest.TestConfig.CLIENT_ID)
|
||||
.queryParam("response_type", "code")
|
||||
.formField("principal", customPrincipal))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrlPattern(ConsentFlowTest.TestConfig.REDIRECT_URI + "?*"))
|
||||
.andReturn();
|
||||
|
||||
String redirectUrl = getRedirectUrl(authorizationResult);
|
||||
String authorizationCode = UriComponentsBuilder.fromUriString(redirectUrl)
|
||||
.build()
|
||||
.getQueryParams()
|
||||
.getFirst("code");
|
||||
|
||||
assertNotNull(authorizationCode, "Should have received an authorization code");
|
||||
|
||||
MvcResult tokenResult = mockMvc.perform(post(getTokenEndpoint())
|
||||
.with(httpBasic(ConsentFlowTest.TestConfig.CLIENT_ID, ConsentFlowTest.TestConfig.CLIENT_SECRET))
|
||||
.formField("grant_type", "authorization_code")
|
||||
.formField("code", authorizationCode))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.access_token").exists())
|
||||
.andReturn();
|
||||
|
||||
JsonNode tokenResponse = objectMapper.readTree(tokenResult.getResponse().getContentAsString());
|
||||
JWTClaimsSet claims = verifyToken(tokenResponse.required("access_token").asText());
|
||||
|
||||
assertEquals(customPrincipal, claims.getSubject());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void regular_users_are_prompted_for_consent() throws Exception {
|
||||
String regularPrincipal = "lowly-regular-user";
|
||||
|
||||
MvcResult authorizationResult = mockMvc.perform(get(getAuthorizationEndpoint())
|
||||
.with(remoteUser(regularPrincipal))
|
||||
.queryParam("client_id", ConsentFlowTest.TestConfig.CLIENT_ID)
|
||||
.queryParam("response_type", "code"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrlPattern("**/oauth2/consent?*"))
|
||||
.andReturn();
|
||||
|
||||
String redirectUrl = getRedirectUrl(authorizationResult);
|
||||
MultiValueMap<String, String> queryParams = UriComponentsBuilder.fromUriString(redirectUrl)
|
||||
.build()
|
||||
.getQueryParams();
|
||||
|
||||
mockMvc.perform(post(getAuthorizationEndpoint())
|
||||
.with(remoteUser(regularPrincipal))
|
||||
.formField("client_id", queryParams.getFirst("client_id"))
|
||||
.formField("state", queryParams.getFirst("state")))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrlPattern(ConsentFlowTest.TestConfig.REDIRECT_URI + "?*"));
|
||||
}
|
||||
|
||||
private static String getRedirectUrl(final MvcResult result) {
|
||||
String redirectedUrl = result.getResponse().getRedirectedUrl();
|
||||
assertNotNull(redirectedUrl, "Should have gotten a redirect URL");
|
||||
return URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
@ -84,13 +85,20 @@ public class ConsentFlowTest extends AbstractMetadataTest {
|
||||
|
||||
@Test
|
||||
public void shows_requested_scopes() throws Exception {
|
||||
attemptAuthorizationWithConsentResponseUsingScopes("some-other-end-user", Set.of("openid", "profile"))
|
||||
attemptAuthorizationWithConsentResponseUsingScopes("some-other-end-user", Set.of("openid", "email", "profile"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpectAll(
|
||||
content().string(containsString("openid")),
|
||||
content().string(containsString("email")),
|
||||
content().string(containsString("profile")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void does_not_ask_for_consent_for_openid_scope() throws Exception {
|
||||
attemptAuthorizationWithConsentResponseUsingScopes("some-other-end-user", Set.of("openid", "profile"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(not(containsString("openid"))));
|
||||
}
|
||||
|
||||
private ResultActions attemptAuthorizationWithConsentResponse(String principal) throws Exception {
|
||||
Set<String> scopes = Set.of();
|
||||
return attemptAuthorizationWithConsentResponseUsingScopes(principal, scopes);
|
||||
@ -109,8 +117,9 @@ public class ConsentFlowTest extends AbstractMetadataTest {
|
||||
.andExpect(redirectedUrlPattern("**/oauth2/consent?**"))
|
||||
.andReturn();
|
||||
|
||||
String consentUrl = result.getResponse().getRedirectedUrl();
|
||||
assertNotNull(consentUrl, "Should have redirected to the consent page");
|
||||
String redirectedUrl = result.getResponse().getRedirectedUrl();
|
||||
assertNotNull(redirectedUrl, "Should have redirected to the consent page");
|
||||
String consentUrl = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8);
|
||||
|
||||
return mockMvc.perform(get(consentUrl)
|
||||
.with(remoteUser(principal)));
|
||||
|
Loading…
x
Reference in New Issue
Block a user