From 44b6b540f5ee11c7fc106b08b300f555196a5110 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Thu, 8 Apr 2021 15:28:24 +0200 Subject: [PATCH] Remember user consent and make consent page configurable Closes #gh-283 --- .../OAuth2AuthorizationServerConfigurer.java | 71 +++- ...moryOAuth2AuthorizationConsentService.java | 105 +++++ .../OAuth2AuthorizationConsent.java | 199 ++++++++++ .../OAuth2AuthorizationConsentService.java | 56 +++ .../OAuth2AuthorizationEndpointFilter.java | 160 +++++++- .../OAuth2AuthorizationCodeGrantTests.java | 172 ++++++++- ...OAuth2AuthorizationConsentServiceTest.java | 152 ++++++++ .../OAuth2AuthorizationConsentTest.java | 117 ++++++ ...Auth2AuthorizationEndpointFilterTests.java | 361 +++++++++++++++++- ...orizationserver-custom-consent-page.gradle | 8 + ...ionServerCustomConsentPageApplication.java | 32 ++ .../config/AuthorizationServerConfig.java | 109 ++++++ .../sample/config/DefaultSecurityConfig.java | 60 +++ .../src/main/java/sample/jose/Jwks.java | 73 ++++ .../java/sample/jose/KeyGeneratorUtils.java | 84 ++++ .../java/sample/web/ConsentController.java | 109 ++++++ .../src/main/resources/application.yml | 10 + .../src/main/resources/templates/consent.html | 87 +++++ 18 files changed, 1929 insertions(+), 36 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTest.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTest.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/spring-security-samples-boot-oauth2-integrated-authorizationserver-custom-consent-page.gradle create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/OAuth2AuthorizationServerCustomConsentPageApplication.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/AuthorizationServerConfig.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/DefaultSecurityConfig.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/Jwks.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/KeyGeneratorUtils.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/web/ConsentController.java create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/application.yml create mode 100644 samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/templates/consent.html diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java index d2a3cb00c..2fd64f2de 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java @@ -36,9 +36,11 @@ import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwsEncoder; import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; @@ -107,6 +109,7 @@ public final class OAuth2AuthorizationServerConfigurer authorizationService(OAuth2Authori return this; } + /** + * Sets the authorization consent service. + * + * @param authorizationConsentService the authorization service + * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration + */ + public OAuth2AuthorizationServerConfigurer authorizationConsentService(OAuth2AuthorizationConsentService authorizationConsentService) { + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + this.getBuilder().setSharedObject(OAuth2AuthorizationConsentService.class, authorizationConsentService); + return this; + } + /** * Sets the provider settings. * @@ -144,6 +159,43 @@ public OAuth2AuthorizationServerConfigurer providerSettings(ProviderSettings return this; } + /** + * Specify the URL to redirect Resource Owners to if consent is required during + * the {@code authorization_code} flow. A default consent page will be generated when + * this attribute is not specified. + * + * If a URL is specified, users are required to process the specified URL to generate + * a consent page. The query string will contain the following parameters: + * + *
    + *
  • {@code client_id} the client identifier
  • + *
  • {@code scope} the space separated list of scopes present in the authorization request
  • + *
  • {@code state} a CSRF protection token
  • + *
+ * + * In general, the consent page should create a form that submits + * a request with the following requirements: + * + *
    + *
  • It must be an HTTP POST
  • + *
  • It must be submitted to {@link ProviderSettings#authorizationEndpoint()}
  • + *
  • It must include the received {@code client_id} as an HTTP parameter
  • + *
  • It must include the received {@code state} as an HTTP parameter
  • + *
  • It must include the list of {@code scope}s the {@code Resource Owners} + * consents to as an HTTP parameter
  • + *
  • It must include the {@code consent_action} parameter, with value either + * {@code approve} or {@code cancel} as an HTTP parameter
  • + *
+ * + * + * @param consentPage the consent page to redirect to if consent is required (e.g. "/consent") + * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration + */ + public OAuth2AuthorizationServerConfigurer consentPage(String consentPage) { + this.consentPage = consentPage; + return this; + } + /** * Returns a {@link RequestMatcher} for the authorization server endpoints. * @@ -263,7 +315,12 @@ public void configure(B builder) { new OAuth2AuthorizationEndpointFilter( getRegisteredClientRepository(builder), getAuthorizationService(builder), - providerSettings.authorizationEndpoint()); + getAuthorizationConsentService(builder), + providerSettings.authorizationEndpoint() + ); + if (this.consentPage != null) { + authorizationEndpointFilter.setUserConsentUri(this.consentPage); + } builder.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); OAuth2TokenEndpointFilter tokenEndpointFilter = @@ -347,6 +404,18 @@ private static > OAuth2AuthorizationService get return authorizationService; } + private static > OAuth2AuthorizationConsentService getAuthorizationConsentService(B builder) { + OAuth2AuthorizationConsentService authorizationConsentService = builder.getSharedObject(OAuth2AuthorizationConsentService.class); + if (authorizationConsentService == null) { + authorizationConsentService = getOptionalBean(builder, OAuth2AuthorizationConsentService.class); + if (authorizationConsentService == null) { + authorizationConsentService = new InMemoryOAuth2AuthorizationConsentService(); + } + builder.setSharedObject(OAuth2AuthorizationConsentService.class, authorizationConsentService); + } + return authorizationConsentService; + } + private static > JwtEncoder getJwtEncoder(B builder) { JwtEncoder jwtEncoder = builder.getSharedObject(JwtEncoder.class); if (jwtEncoder == null) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java new file mode 100644 index 000000000..caef7aa33 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java @@ -0,0 +1,105 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * An {@link OAuth2AuthorizationConsentService} that stores {@link OAuth2AuthorizationConsent}'s in-memory. + * + *

+ * NOTE: This implementation should ONLY be used during development/testing. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.2 + * @see OAuth2AuthorizationConsentService + */ +public final class InMemoryOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService { + private final Map authorizationConsents = new ConcurrentHashMap<>(); + + /** + * Constructs an {@code InMemoryOAuth2AuthorizationConsentService}. + */ + public InMemoryOAuth2AuthorizationConsentService() { + this(Collections.emptyList()); + } + + /** + * Constructs an {@code InMemoryOAuth2AuthorizationConsentService} using the provided parameters. + * + * @param authorizationConsents the authorization consent(s) + */ + public InMemoryOAuth2AuthorizationConsentService(OAuth2AuthorizationConsent... authorizationConsents) { + this(Arrays.asList(authorizationConsents)); + } + + /** + * Constructs an {@code InMemoryOAuth2AuthorizationConsentService} using the provided parameters. + * + * @param authorizationConsents the authorization consent(s) + */ + public InMemoryOAuth2AuthorizationConsentService(List authorizationConsents) { + Assert.notNull(authorizationConsents, "authorizationConsents cannot be null"); + authorizationConsents.forEach(authorizationConsent -> { + Assert.notNull(authorizationConsent, "authorizationConsent cannot be null"); + int id = getId(authorizationConsent); + Assert.isTrue(!this.authorizationConsents.containsKey(id), + "The authorizationConsent must be unique. Found duplicate, with registered client id: [" + + authorizationConsent.getRegisteredClientId() + + "] and principal name: [" + authorizationConsent.getPrincipalName() + "]"); + this.authorizationConsents.put(id, authorizationConsent); + }); + } + + @Override + public void save(OAuth2AuthorizationConsent authorizationConsent) { + Assert.notNull(authorizationConsent, "authorizationConsent cannot be null"); + int id = getId(authorizationConsent); + this.authorizationConsents.put(id, authorizationConsent); + } + + @Override + public void remove(OAuth2AuthorizationConsent authorizationConsent) { + Assert.notNull(authorizationConsent, "authorizationConsent cannot be null"); + int id = getId(authorizationConsent); + this.authorizationConsents.remove(id, authorizationConsent); + } + + @Override + @Nullable + public OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) { + Assert.hasText(registeredClientId, "registeredClientId cannot be empty"); + Assert.hasText(principalName, "principalName cannot be empty"); + int id = getId(registeredClientId, principalName); + return this.authorizationConsents.get(id); + } + + private static int getId(String registeredClientId, String principalName) { + return Objects.hash(registeredClientId, principalName); + } + + private static int getId(OAuth2AuthorizationConsent authorizationConsent) { + return getId(authorizationConsent.getRegisteredClientId(), authorizationConsent.getPrincipalName()); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java new file mode 100644 index 000000000..60ffae277 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java @@ -0,0 +1,199 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization; + +import org.springframework.lang.NonNull; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.Version; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * A representation of an OAuth 2.0 "consent" to an Authorization request, which holds state related to the + * set of {@link #getAuthorities()} authorities} granted to a {@link #getRegisteredClientId() client} by the + * {@link #getPrincipalName() resource owner}. + *

+ * When authorizing access for a given client, the resource owner may only grant a subset of the authorities + * the client requested. The typical use-case is the {@code authorization_code} flow, in which the client + * requests a set of {@code scope}s. The resource owner then selects which scopes they grant to the client. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.2 + */ +public final class OAuth2AuthorizationConsent implements Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + private static final String AUTHORITIES_SCOPE_PREFIX = "SCOPE_"; + + private final String registeredClientId; + private final String principalName; + private final Set authorities; + + private OAuth2AuthorizationConsent(String registeredClientId, String principalName, Set authorities) { + this.registeredClientId = registeredClientId; + this.principalName = principalName; + this.authorities = Collections.unmodifiableSet(authorities); + } + + /** + * Returns the identifier for the {@link RegisteredClient#getId() registered client}. + * + * @return the {@link RegisteredClient#getId()} + */ + public String getRegisteredClientId() { + return this.registeredClientId; + } + + /** + * Returns the {@code Principal} name of the resource owner (or client). + * + * @return the {@code Principal} name of the resource owner (or client) + */ + public String getPrincipalName() { + return this.principalName; + } + + /** + * Returns the {@link GrantedAuthority authorities} granted to the client by the principal. + * + * @return the {@link GrantedAuthority authorities} granted to the client by the principal. + */ + public Set getAuthorities() { + return this.authorities; + } + + /** + * Convenience method for obtaining the {@code scope}s granted to the client by the principal, + * extracted from the {@link #getAuthorities() authorities}. + * + * @return the {@code scope}s granted to the client by the principal. + */ + public Set getScopes() { + return getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .filter(authority -> authority.startsWith(AUTHORITIES_SCOPE_PREFIX)) + .map(scope -> scope.replaceFirst(AUTHORITIES_SCOPE_PREFIX, "")) + .collect(Collectors.toSet()); + } + + /** + * Returns a new {@link Builder}, initialized with the values from the provided {@code OAuth2AuthorizationConsent}. + * + * @param authorizationConsent the {@code OAuth2AuthorizationConsent} used for initializing the {@link Builder} + * @return the {@link Builder} + */ + public static Builder from(OAuth2AuthorizationConsent authorizationConsent) { + Assert.notNull(authorizationConsent, "authorizationConsent cannot be null"); + return new Builder( + authorizationConsent.getRegisteredClientId(), + authorizationConsent.getPrincipalName(), + authorizationConsent.getAuthorities() + ); + } + + /** + * Returns a new {@link Builder}, initialized with the given {@link RegisteredClient#getClientId() registeredClientId} + * and {@code Principal} name. + * + * @param registeredClientId the {@link RegisteredClient#getId()} + * @param principalName the {@code Principal} name + * @return the {@link Builder} + */ + public static Builder withId(@NonNull String registeredClientId, @NonNull String principalName) { + Assert.hasText(registeredClientId, "registeredClientId cannot be empty"); + Assert.hasText(principalName, "principalName cannot be empty"); + return new Builder(registeredClientId, principalName); + } + + + /** + * A builder for {@link OAuth2AuthorizationConsent}. + */ + public final static class Builder implements Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + + private final String registeredClientId; + private final String principalName; + private final Set authorities = new HashSet<>(); + + private Builder(String registeredClientId, String principalName) { + this(registeredClientId, principalName, Collections.emptySet()); + } + + private Builder(String registeredClientId, String principalName, Set authorities) { + this.registeredClientId = registeredClientId; + this.principalName = principalName; + if (!CollectionUtils.isEmpty(authorities)) { + this.authorities.addAll(authorities); + } + } + + /** + * Adds a scope to the collection of {@code authorities} in the resulting {@link OAuth2AuthorizationConsent}, + * wrapping it in a SimpleGrantedAuthority, prefixed by {@code SCOPE_}. For example, a + * {@code message.write} scope would be stored as {@code SCOPE_message.write}. + * + * @param scope the {@code scope} + * @return the {@code Builder} for further configuration + */ + public Builder scope(String scope) { + authority(new SimpleGrantedAuthority(AUTHORITIES_SCOPE_PREFIX + scope)); + return this; + } + + /** + * Adds a {@link GrantedAuthority} to the collection of {@code authorities} in the + * resulting {@link OAuth2AuthorizationConsent}. + * + * @param authority the {@link GrantedAuthority} + * @return the {@code Builder} for further configuration + */ + public Builder authority(GrantedAuthority authority) { + this.authorities.add(authority); + return this; + } + + /** + * A {@code Consumer} of the {@code authorities}, allowing the ability to add, replace or remove. + * + * @param authoritiesConsumer a {@code Consumer} of the {@code authorities} + * @return the {@code Builder} for further configuration + */ + public Builder authorities(Consumer> authoritiesConsumer) { + authoritiesConsumer.accept(this.authorities); + return this; + } + + /** + * Validate the authorities and build the {@link OAuth2AuthorizationConsent}. + * There must be at least one {@link GrantedAuthority}. + * + * @return the {@link OAuth2AuthorizationConsent} + */ + public OAuth2AuthorizationConsent build() { + Assert.notEmpty(this.authorities, "authorities cannot be empty"); + return new OAuth2AuthorizationConsent(this.registeredClientId, this.principalName, this.authorities); + } + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java new file mode 100644 index 000000000..185a69d1d --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization; + +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; + +import java.security.Principal; + +/** + * Implementations of this interface are responsible for the management + * of {@link OAuth2AuthorizationConsent OAuth 2.0 Authorization Consent(s)}. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.2 + * @see OAuth2AuthorizationConsent + */ +public interface OAuth2AuthorizationConsentService { + /** + * Saves the {@link OAuth2AuthorizationConsent}. + * + * @param authorizationConsent the {@link OAuth2AuthorizationConsent} + */ + void save(OAuth2AuthorizationConsent authorizationConsent); + + /** + * Removes the {@link OAuth2AuthorizationConsent}. + * + * @param authorizationConsent the {@link OAuth2AuthorizationConsent} + */ + void remove(OAuth2AuthorizationConsent authorizationConsent); + + /** + * Returns the {@link OAuth2AuthorizationConsent} identified by the provided + * {@code registeredClientId} and {@code principalName}, or {@code null} if not found. + * + * @param registeredClientId the identifier for the {@link RegisteredClient} + * @param principalName the name of the {@link Principal} + * @return the {@link OAuth2AuthorizationConsent} if found, otherwise {@code null} + */ + @Nullable + OAuth2AuthorizationConsent findById(String registeredClientId, String principalName); +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java index 2be1d5de1..596473c32 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java @@ -50,8 +50,11 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; @@ -81,6 +84,7 @@ * @since 0.0.1 * @see RegisteredClientRepository * @see OAuth2AuthorizationService + * @see OAuth2AuthorizationConsentService * @see OAuth2Authorization * @see Section 4.1 Authorization Code Grant * @see Section 4.1.1 Authorization Request @@ -99,21 +103,27 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { private final RegisteredClientRepository registeredClientRepository; private final OAuth2AuthorizationService authorizationService; + private final OAuth2AuthorizationConsentService authorizationConsentService; private final RequestMatcher authorizationRequestMatcher; private final RequestMatcher userConsentMatcher; private final StringKeyGenerator codeGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96); private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder()); private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + private String userConsentUri; /** * Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters. * * @param registeredClientRepository the repository of registered clients * @param authorizationService the authorization service + * @deprecated use + * {@link #OAuth2AuthorizationEndpointFilter(RegisteredClientRepository, OAuth2AuthorizationService, OAuth2AuthorizationConsentService)} + * instead. */ + @Deprecated public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, OAuth2AuthorizationService authorizationService) { - this(registeredClientRepository, authorizationService, DEFAULT_AUTHORIZATION_ENDPOINT_URI); + this(registeredClientRepository, authorizationService, new InMemoryOAuth2AuthorizationConsentService()); } /** @@ -122,14 +132,49 @@ public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredCl * @param registeredClientRepository the repository of registered clients * @param authorizationService the authorization service * @param authorizationEndpointUri the endpoint {@code URI} for authorization requests + * @deprecated use + * {@link #OAuth2AuthorizationEndpointFilter(RegisteredClientRepository, OAuth2AuthorizationService, OAuth2AuthorizationConsentService, String)} + * instead. */ + @Deprecated public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, OAuth2AuthorizationService authorizationService, String authorizationEndpointUri) { + this(registeredClientRepository, + authorizationService, + new InMemoryOAuth2AuthorizationConsentService(), + authorizationEndpointUri); + } + + /** + * Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param authorizationConsentService the authorization consent service + */ + public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService authorizationConsentService) { + this(registeredClientRepository, authorizationService, authorizationConsentService, DEFAULT_AUTHORIZATION_ENDPOINT_URI); + } + + /** + * Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param consentService the consent service + * @param authorizationEndpointUri the endpoint {@code URI} for authorization requests + */ + public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService consentService, + String authorizationEndpointUri) { Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(consentService, "consentService cannot be null"); Assert.hasText(authorizationEndpointUri, "authorizationEndpointUri cannot be empty"); this.registeredClientRepository = registeredClientRepository; this.authorizationService = authorizationService; + this.authorizationConsentService = consentService; RequestMatcher authorizationRequestGetMatcher = new AntPathRequestMatcher( authorizationEndpointUri, HttpMethod.GET.name()); @@ -150,6 +195,17 @@ public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredCl authorizationRequestPostMatcher, consentActionMatcher); } + /** + * Specify the URL to redirect Resource Owners to if consent is required. A default consent + * page will be generated when this attribute is not specified. + * + * @param customConsentUri the URI of the custom consent page to redirect to if consent is required (e.g. "/consent") + * @see org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer#consentPage(String) + */ + public final void setUserConsentUri(String customConsentUri) { + this.userConsentUri = customConsentUri; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -203,7 +259,8 @@ private void processAuthorizationRequest(HttpServletRequest request, HttpServlet .attribute(Principal.class.getName(), principal) .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest); - if (requireUserConsent(registeredClient, authorizationRequest)) { + OAuth2AuthorizationConsent previousConsent = this.authorizationConsentService.findById(registeredClient.getClientId(), principal.getName()); + if (requireUserConsent(registeredClient, authorizationRequest, previousConsent)) { String state = this.stateGenerator.generateKey(); OAuth2Authorization authorization = builder .attribute(OAuth2ParameterNames.STATE, state) @@ -212,7 +269,17 @@ private void processAuthorizationRequest(HttpServletRequest request, HttpServlet // TODO Need to remove 'in-flight' authorization if consent step is not completed (e.g. approved or cancelled) - UserConsentPage.displayConsent(request, response, registeredClient, authorization); + if (this.hasCustomUserConsentPage()) { + String redirect = UriComponentsBuilder + .fromUriString(this.userConsentUri) + .queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", authorizationRequest.getScopes())) + .queryParam(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()) + .queryParam(OAuth2ParameterNames.STATE, state) + .toUriString(); + this.redirectStrategy.sendRedirect(request, response, redirect); + } else { + UserConsentPage.displayConsent(request, response, registeredClient, authorization, previousConsent); + } } else { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live @@ -237,13 +304,26 @@ private void processAuthorizationRequest(HttpServletRequest request, HttpServlet } } - private static boolean requireUserConsent(RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest) { + private boolean hasCustomUserConsentPage() { + return this.userConsentUri != null; + } + + private boolean requireUserConsent(RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent previousConsent) { + if (!registeredClient.getClientSettings().requireUserConsent()) { + return false; + } // openid scope does not require consent if (authorizationRequest.getScopes().contains(OidcScopes.OPENID) && authorizationRequest.getScopes().size() == 1) { return false; } - return registeredClient.getClientSettings().requireUserConsent(); + + if (previousConsent != null && + previousConsent.getScopes().containsAll(authorizationRequest.getScopes())) { + return false; + } + + return true; } private void processUserConsent(HttpServletRequest request, HttpServletResponse response) @@ -283,6 +363,18 @@ private void processUserConsent(HttpServletRequest request, HttpServletResponse // openid scope is auto-approved as it does not require consent authorizedScopes.add(OidcScopes.OPENID); } + + OAuth2AuthorizationConsent previousConsent = this.authorizationConsentService.findById( + userConsentRequestContext.getClientId(), + userConsentRequestContext.getAuthorization().getPrincipalName() + ); + for (String requestedScope : userConsentRequestContext.getAuthorizationRequest().getScopes()) { + if (previousConsent != null && previousConsent.getScopes().contains(requestedScope)) { + authorizedScopes.add(requestedScope); + } + } + saveAuthorizationConsent(previousConsent, userConsentRequestContext); + OAuth2Authorization authorization = OAuth2Authorization.from(userConsentRequestContext.getAuthorization()) .token(authorizationCode) .attributes(attrs -> { @@ -296,6 +388,28 @@ private void processUserConsent(HttpServletRequest request, HttpServletResponse authorizationCode, userConsentRequestContext.getAuthorizationRequest().getState()); } + private void saveAuthorizationConsent(OAuth2AuthorizationConsent previousConsent, UserConsentRequestContext userConsentRequestContext) { + if (CollectionUtils.isEmpty(userConsentRequestContext.getScopes())) { + return; + } + + OAuth2AuthorizationConsent.Builder userConsentBuilder; + if (previousConsent == null) { + userConsentBuilder = OAuth2AuthorizationConsent.withId( + userConsentRequestContext.getClientId(), + userConsentRequestContext.getAuthorization().getPrincipalName() + ); + } else { + userConsentBuilder = OAuth2AuthorizationConsent.from(previousConsent); + } + + for (String authorizedScope : userConsentRequestContext.getScopes()) { + userConsentBuilder.scope(authorizedScope); + } + OAuth2AuthorizationConsent userConsent = userConsentBuilder.build(); + this.authorizationConsentService.save(userConsent); + } + private void validateAuthorizationRequest(OAuth2AuthorizationRequestContext authorizationRequestContext) { // --------------- // Validate the request to ensure all required parameters are present and valid @@ -600,7 +714,7 @@ private UserConsentRequestContext( private static Set extractScopes(MultiValueMap parameters) { List scope = parameters.get(OAuth2ParameterNames.SCOPE); - return !CollectionUtils.isEmpty(scope) ? new HashSet<>(scope) : Collections.emptySet(); + return !CollectionUtils.isEmpty(scope) ? new HashSet<>(scope) : new HashSet<>(); } private OAuth2Authorization getAuthorization() { @@ -700,9 +814,10 @@ private static class UserConsentPage { private static final String CONSENT_ACTION_CANCEL = "cancel"; private static void displayConsent(HttpServletRequest request, HttpServletResponse response, - RegisteredClient registeredClient, OAuth2Authorization authorization) throws IOException { + RegisteredClient registeredClient, OAuth2Authorization authorization, + OAuth2AuthorizationConsent previousConsent) throws IOException { - String consentPage = generateConsentPage(request, registeredClient, authorization); + String consentPage = generateConsentPage(request, registeredClient, authorization, previousConsent); response.setContentType(TEXT_HTML_UTF8.toString()); response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length); response.getWriter().write(consentPage); @@ -717,14 +832,21 @@ private static boolean isConsentCancelled(HttpServletRequest request) { } private static String generateConsentPage(HttpServletRequest request, - RegisteredClient registeredClient, OAuth2Authorization authorization) { - + RegisteredClient registeredClient, OAuth2Authorization authorization, OAuth2AuthorizationConsent previousConsent) { OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( OAuth2AuthorizationRequest.class.getName()); - Set scopes = new HashSet<>(authorizationRequest.getScopes()); - scopes.remove(OidcScopes.OPENID); // openid scope does not require consent - String state = authorization.getAttribute( - OAuth2ParameterNames.STATE); + + Set scopes = new HashSet<>(); + Set previouslyApprovedScopes = new HashSet<>(); + for (String scope : authorizationRequest.getScopes()) { + if (previousConsent != null && previousConsent.getScopes().contains(scope)) { + previouslyApprovedScopes.add(scope); + } else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent + scopes.add(scope); + } + } + + String state = authorization.getAttribute(OAuth2ParameterNames.STATE); StringBuilder builder = new StringBuilder(); @@ -764,6 +886,16 @@ private static String generateConsentPage(HttpServletRequest request, builder.append(" "); } + if (!previouslyApprovedScopes.isEmpty()) { + builder.append("

You have already granted the following permissions to the above app:

"); + for (String scope : previouslyApprovedScopes) { + builder.append("
"); + builder.append(" "); + builder.append(" "); + builder.append("
"); + } + } + builder.append("
"); builder.append(" "); builder.append("
"); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java index 2bac7ff39..a52e27c1f 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java @@ -15,9 +15,11 @@ */ package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization; +import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.Principal; +import java.text.MessageFormat; import java.util.Base64; import java.util.List; import java.util.Set; @@ -40,6 +42,7 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.config.test.SpringTestRule; @@ -71,11 +74,15 @@ import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.containsString; @@ -115,6 +122,7 @@ public class OAuth2AuthorizationCodeGrantTests { private static ProviderSettings providerSettings; private static HttpMessageConverter accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + private static String consentPage = "/custom-consent"; @Rule public final SpringTestRule spring = new SpringTestRule(); @@ -237,11 +245,9 @@ public void requestWhenTokenRequestCustomEndpointThenReturnAccessTokenResponse() private OAuth2AccessTokenResponse assertTokenRequestReturnsAccessTokenResponse(RegisteredClient registeredClient, OAuth2Authorization authorization, String tokenEndpointUri) throws Exception { - MvcResult mvcResult = this.mvc.perform(post(tokenEndpointUri) .params(getTokenRequestParameters(registeredClient, authorization)) - .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( - registeredClient.getClientId(), registeredClient.getClientSecret()))) + .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) @@ -296,6 +302,8 @@ public void requestWhenPublicClientWithPkceThenReturnAccessTokenResponse() throw .params(getTokenRequestParameters(registeredClient, authorization)) .param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()) .param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) + .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").isNotEmpty()) .andExpect(jsonPath("$.token_type").isNotEmpty()) @@ -326,8 +334,128 @@ public void requestWhenCustomJwtEncoderThenUsed() throws Exception { this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI) .params(getTokenRequestParameters(registeredClient, authorization)) - .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( - registeredClient.getClientId(), registeredClient.getClientSecret()))); + .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))); + } + + @Test + public void requestWhenRequiresConsentThenDisplaysConsentPage() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(settings -> settings.requireUserConsent(true)) + .build(); + when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + String consentPage = this.mvc.perform(get(OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .params(getAuthorizationRequestParameters(registeredClient)) + .with(user("user"))) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + + + assertThat(consentPage).contains("Consent required"); + assertThat(consentPage).contains(scopeCheckbox("message.read")); + assertThat(consentPage).contains(scopeCheckbox("message.write")); + } + + @Test + public void requestWhenConsentRequestReturnAccessTokenResponse() throws Exception { + final String stateParameter = "consent-state"; + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(settings -> settings.requireUserConsent(true)) + .build(); + when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2Authorization stateTokenAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName("user") + .build(); + + when(authorizationService.findByToken( + eq(stateParameter), + eq(new OAuth2TokenType(OAuth2ParameterNames.STATE)))) + .thenReturn(stateTokenAuthorization); + + MvcResult mvcResult = this.mvc.perform(post(OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()) + .param(OAuth2ParameterNames.SCOPE, "message.read") + .param(OAuth2ParameterNames.SCOPE, "message.write") + .param(OAuth2ParameterNames.STATE, stateParameter) + .param("consent_action", "approve") + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + assertThat(mvcResult.getResponse().getRedirectedUrl()).matches("https://example.com\\?code=.{15,}&state=state"); + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization authorizationCodeAuthorization = authorizationCaptor.getValue(); + when(authorizationService.findByToken( + eq(authorizationCodeAuthorization.getToken(OAuth2AuthorizationCode.class).getToken().getTokenValue()), + eq(AUTHORIZATION_CODE_TOKEN_TYPE))) + .thenReturn(authorizationCodeAuthorization); + + this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization)) + .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) + .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andReturn(); + } + + @Test + public void requestWhenCustomConsentPageConfiguredThenRedirect() throws Exception { + this.spring.register(AuthorizationServerConfigurationCustomConsentPage.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(settings -> settings.requireUserConsent(true)) + .build(); + when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + MvcResult mvcResult = this.mvc.perform(get(OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .params(getAuthorizationRequestParameters(registeredClient)) + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String locationHeader = URLDecoder.decode(mvcResult.getResponse().getRedirectedUrl(), StandardCharsets.UTF_8.name()); + UriComponents redirectedUrl = UriComponentsBuilder.fromUriString(locationHeader).build(); + MultiValueMap redirectQueryParams = redirectedUrl.getQueryParams(); + + assertThat(redirectedUrl.getPath()).isEqualTo(consentPage); + assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("message.read message.write"); + assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo(registeredClient.getClientId()); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.STATE)).isEqualTo(authorization.getAttribute(OAuth2ParameterNames.STATE)); } private static MultiValueMap getAuthorizationRequestParameters(RegisteredClient registeredClient) { @@ -350,12 +478,21 @@ private static MultiValueMap getTokenRequestParameters(Registere return parameters; } - private static String encodeBasicAuth(String clientId, String secret) throws Exception { + private static String getAuthorizationHeader(RegisteredClient registeredClient) throws Exception { + String clientId = registeredClient.getClientId(); + String secret = registeredClient.getClientSecret(); clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name()); secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name()); String credentialsString = clientId + ":" + secret; byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8)); - return new String(encodedBytes, StandardCharsets.UTF_8); + return "Basic " + new String(encodedBytes, StandardCharsets.UTF_8); + } + + private static String scopeCheckbox(String scope) { + return MessageFormat.format( + "", + scope + ); } @EnableWebSecurity @@ -418,4 +555,25 @@ ProviderSettings providerSettings() { } } + @EnableWebSecurity + static class AuthorizationServerConfigurationCustomConsentPage extends AuthorizationServerConfiguration { + // @formatter:off + @Bean + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer<>(); + authorizationServerConfigurer.consentPage(consentPage); + RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher(); + + http + .requestMatcher(endpointsMatcher) + .authorizeRequests(authorizeRequests -> + authorizeRequests.anyRequest().authenticated() + ) + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .apply(authorizationServerConfigurer); + return http.build(); + } + // @formatter:on + } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTest.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTest.java new file mode 100644 index 000000000..505d8cd81 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link InMemoryOAuth2AuthorizationConsentService}. + * + * @author Daniel Garnier-Moiroux + */ +public class InMemoryOAuth2AuthorizationConsentServiceTest { + private InMemoryOAuth2AuthorizationConsentService consentService; + + private static final String CLIENT_ID = "client-id"; + private static final String PRINCIPAL_NAME = "principal-name"; + private static final OAuth2AuthorizationConsent CONSENT = OAuth2AuthorizationConsent + .withId(CLIENT_ID, PRINCIPAL_NAME) + .authority(new SimpleGrantedAuthority("some.authority")) + .build(); + + @Before + public void setUp() throws Exception { + this.consentService = new InMemoryOAuth2AuthorizationConsentService(); + this.consentService.save(CONSENT); + } + + @Test + public void constructorVaragsWhenAuthorizationConsentNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService((OAuth2AuthorizationConsent) null)) + .withMessage("authorizationConsent cannot be null"); + } + + @Test + public void constructorListWhenAuthorizationConsentsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService((List) null)) + .withMessage("authorizationConsents cannot be null"); + } + + @Test + public void constructorWhenDuplicateAuthorizationConsentsThenThrowIllegalArgumentException() { + OAuth2AuthorizationConsent authorizationConsent = OAuth2AuthorizationConsent.withId("client-id", "principal-name") + .scope("thing.write") // must have at least one scope + .build(); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService(authorizationConsent, authorizationConsent)) + .withMessage("The authorizationConsent must be unique. Found duplicate, with registered client id: [client-id] and principal name: [principal-name]"); + } + + @Test + public void saveWhenConsentNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.consentService.save(null)) + .withMessage("authorizationConsent cannot be null"); + } + + @Test + public void saveWhenConsentNewThenSaved() { + OAuth2AuthorizationConsent expectedConsent = OAuth2AuthorizationConsent + .withId("new-client", "new-principal") + .authority(new SimpleGrantedAuthority("new.authority")) + .build(); + + this.consentService.save(expectedConsent); + + OAuth2AuthorizationConsent consent = + this.consentService.findById("new-client", "new-principal"); + assertThat(consent).isEqualTo(expectedConsent); + } + + @Test + public void saveWhenConsentExistsThenUpdated() { + OAuth2AuthorizationConsent expectedConsent = OAuth2AuthorizationConsent + .from(CONSENT) + .authority(new SimpleGrantedAuthority("new.authority")) + .build(); + + this.consentService.save(expectedConsent); + + OAuth2AuthorizationConsent consent = + this.consentService.findById(CLIENT_ID, PRINCIPAL_NAME); + assertThat(consent).isEqualTo(expectedConsent); + assertThat(consent).isNotEqualTo(CONSENT); + + } + + @Test + public void removeNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.consentService.remove(null)) + .withMessage("authorizationConsent cannot be null"); + } + + @Test + public void removeWhenConsentProvidedThenRemoved() { + this.consentService.remove(CONSENT); + + assertThat(this.consentService.findById(CLIENT_ID, PRINCIPAL_NAME)) + .isNull(); + } + + @Test + public void findWhenRegisteredClientIdNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.consentService.findById(null, "some-user")) + .withMessage("registeredClientId cannot be empty"); + } + + @Test + public void findWhenPrincipalNameNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.consentService.findById("some-client", null)) + .withMessage("principalName cannot be empty"); + } + + @Test + public void findWhenConsentExistsThenFound() { + assertThat(this.consentService.findById(CLIENT_ID, PRINCIPAL_NAME)) + .isEqualTo(CONSENT); + } + + @Test + public void findWhenConsentDoesNotExistThenNull() { + this.consentService.save(CONSENT); + + assertThat(this.consentService.findById("unknown-client", PRINCIPAL_NAME)).isNull(); + assertThat(this.consentService.findById(CLIENT_ID, "unkown-user")).isNull(); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTest.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTest.java new file mode 100644 index 000000000..4772d5382 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization; + +import org.junit.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2AuthorizationConsent}. + * + * @author Daniel Garnier-Moiroux + */ +public class OAuth2AuthorizationConsentTest { + @Test + public void fromWhenAuthorizationConsentNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationConsent.from(null)) + .withMessage("authorizationConsent cannot be null"); + } + + @Test + public void withClientIdAndPrincipalWhenClientIdNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationConsent.withId(null, "some-user")) + .withMessage("registeredClientId cannot be empty"); + } + + @Test + public void withClientIdAndPrincipalWhenPrincipalNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationConsent.withId("some-client", null)) + .withMessage("principalName cannot be empty"); + } + + @Test + public void buildWhenAuthoritiesEmptyThenThrowIllegalArgumentException() { + OAuth2AuthorizationConsent.Builder builder = OAuth2AuthorizationConsent.withId("some-client", "some-user"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("authorities cannot be empty"); + } + + @Test + public void buildWhenAllAttributesAreProvidedThenAllAttributesAreSet() { + OAuth2AuthorizationConsent consent = OAuth2AuthorizationConsent + .withId("some-client", "some-user") + .scope("resource.read") + .scope("resource.write") + .authority(new SimpleGrantedAuthority("CLAIM_email")) + .build(); + + assertThat(consent.getPrincipalName()).isEqualTo("some-user"); + assertThat(consent.getRegisteredClientId()).isEqualTo("some-client"); + assertThat(consent.getScopes()) + .containsExactlyInAnyOrder( + "resource.read", + "resource.write" + ); + assertThat(consent.getAuthorities()) + .containsExactlyInAnyOrder( + new SimpleGrantedAuthority("SCOPE_resource.read"), + new SimpleGrantedAuthority("SCOPE_resource.write"), + new SimpleGrantedAuthority("CLAIM_email") + ); + } + + @Test + public void fromWhenAuthorizationConsentProvidedThenCopied() { + OAuth2AuthorizationConsent previousConsent = OAuth2AuthorizationConsent + .withId("some-client", "some-principal") + .scope("first.scope") + .scope("second.scope") + .authority(new SimpleGrantedAuthority("CLAIM_email")) + .build(); + + OAuth2AuthorizationConsent consent = OAuth2AuthorizationConsent.from(previousConsent).build(); + + assertThat(consent.getPrincipalName()).isEqualTo("some-principal"); + assertThat(consent.getRegisteredClientId()).isEqualTo("some-client"); + assertThat(consent.getAuthorities()) + .containsExactlyInAnyOrder( + new SimpleGrantedAuthority("SCOPE_first.scope"), + new SimpleGrantedAuthority("SCOPE_second.scope"), + new SimpleGrantedAuthority("CLAIM_email") + ); + } + + @Test + public void authoritiesThenCustomizesAuthorities() { + OAuth2AuthorizationConsent consent = OAuth2AuthorizationConsent + .withId("some-client", "some-user") + .authority(new SimpleGrantedAuthority("some.authority")) + .authorities(authorities -> { + authorities.clear(); + authorities.add(new SimpleGrantedAuthority("other.authority")); + }) + .build(); + + assertThat(consent.getAuthorities()).containsExactly(new SimpleGrantedAuthority("other.authority")); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java index c1ad437cf..37293a7de 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java @@ -15,8 +15,13 @@ */ package org.springframework.security.oauth2.server.authorization.web; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.Principal; +import java.text.MessageFormat; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.Set; import java.util.function.Consumer; @@ -28,7 +33,7 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; - +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletRequest; @@ -46,20 +51,24 @@ import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; -import org.springframework.security.oauth2.server.authorization.config.ClientSettings; -import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -80,13 +89,15 @@ public class OAuth2AuthorizationEndpointFilterTests { private RegisteredClientRepository registeredClientRepository; private OAuth2AuthorizationService authorizationService; private OAuth2AuthorizationEndpointFilter filter; + private OAuth2AuthorizationConsentService consentService; private TestingAuthenticationToken authentication; @Before public void setUp() { this.registeredClientRepository = mock(RegisteredClientRepository.class); this.authorizationService = mock(OAuth2AuthorizationService.class); - this.filter = new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService); + this.consentService = mock(OAuth2AuthorizationConsentService.class); + this.filter = new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, this.consentService); this.authentication = new TestingAuthenticationToken("principalName", "password"); this.authentication.setAuthenticated(true); SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); @@ -101,21 +112,28 @@ public void cleanup() { @Test public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(null, this.authorizationService)) + assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(null, this.authorizationService, this.consentService)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("registeredClientRepository cannot be null"); } @Test public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, null)) + assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, null, this.consentService)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("authorizationService cannot be null"); } + @Test + public void constructorWhenConsentServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, (OAuth2AuthorizationConsentService) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("consentService cannot be null"); + } + @Test public void constructorWhenAuthorizationEndpointUriNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, null)) + assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, this.consentService, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("authorizationEndpointUri cannot be empty"); } @@ -468,7 +486,7 @@ public void doFilterWhenAuthenticationRequestIncludesOnlyOpenidScopeThenDoesNotR scopes.clear(); scopes.add(OidcScopes.OPENID); }) - .clientSettings(ClientSettings::requireUserConsent) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) .build(); MockHttpServletRequest request = createAuthorizationRequest(registeredClient); doFilterWhenAuthorizationRequestThenAuthorizationResponse(registeredClient, request); @@ -570,7 +588,7 @@ public void doFilterWhenPkceRequiredAndAuthorizationRequestThenAuthorizationResp } @Test - public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenUserConsentResponse() throws Exception { + public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenSavesAuthorization() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient() .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) .build(); @@ -583,11 +601,6 @@ public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenUserConsen this.filter.doFilter(request, response, filterChain); - verifyNoInteractions(filterChain); - - assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); - assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString())); - ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); verify(this.authorizationService).save(authorizationCaptor.capture()); @@ -617,6 +630,150 @@ public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenUserConsen assertThat(authorizationRequest.getAdditionalParameters()).isEmpty(); } + @Test + public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenUserConsentResponse() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString())); + + assertThat(response.getContentAsString()).contains(scopeCheckbox("message.read")); + assertThat(response.getContentAsString()).contains(scopeCheckbox("message.write")); + + verifyNoInteractions(filterChain); + } + + @Test + public void doFilterWhenUserConsentRequiredAndPreviouslyApprovedAndAuthorizationRequestThenUserConsentResponse() throws Exception { + String unrelatedPreviouslyApprovedScope = "unrelated.scope"; + String previouslyApprovedScope = "message.read"; + String newScope = "message.write"; + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add(previouslyApprovedScope); + scopes.add(newScope); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + OAuth2AuthorizationConsent previousConsent = createConsent( + registeredClient.getClientId(), + this.authentication.getName(), + Arrays.asList(previouslyApprovedScope, unrelatedPreviouslyApprovedScope) + ); + when(this.consentService.findById( + eq(registeredClient.getClientId()), + eq(this.authentication.getName()))) + .thenReturn(previousConsent); + + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString())); + + assertThat(response.getContentAsString()).contains(scopeCheckbox(newScope)); + assertThat(response.getContentAsString()).contains(disabledScopeCheckbox(previouslyApprovedScope)); + assertThat(response.getContentAsString()).doesNotContain(unrelatedPreviouslyApprovedScope); + } + + @Test + public void doFilterWhenUserConsentRequiredAndCustomConsentUriAndAuthorizationRequestThenRedirects() throws Exception { + this.filter.setUserConsentUri("/consent"); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + + verify(this.authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization authorization = authorizationCaptor.getValue(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + + String consentRedirectHeader = URLDecoder.decode(response.getHeader(HttpHeaders.LOCATION), StandardCharsets.UTF_8.name()); + UriComponents consentRedirectUri = UriComponentsBuilder.fromUriString(consentRedirectHeader).build(); + String[] redirectScopes = consentRedirectUri.getQueryParams().getFirst(OAuth2ParameterNames.SCOPE).split(" "); + String redirectState = consentRedirectUri.getQueryParams().getFirst(OAuth2ParameterNames.STATE); + + assertThat(consentRedirectUri.getPath()).isEqualTo("/consent"); + assertThat(consentRedirectUri.getQueryParams().getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo(registeredClient.getClientId()); + assertThat(redirectScopes).containsExactlyInAnyOrder("message.read", "message.write"); + assertThat(redirectState).isEqualTo(authorization.getAttribute(OAuth2ParameterNames.STATE)); + } + + @Test + public void doFilterWhenUserConsentRequiredAndAllScopesPreviouslyApprovedAndAuthorizationRequestThenAuthorizationResponse() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + OAuth2AuthorizationConsent previousConsent = createConsent( + registeredClient.getClientId(), this.authentication.getName(), Arrays.asList("message.read", "message.write") + ); + when(this.consentService.findById( + eq(registeredClient.getClientId()), + eq(this.authentication.getName()))) + .thenReturn(previousConsent); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).matches("https://example.com\\?code=.{15,}&state=state"); + } + @Test public void doFilterWhenUserConsentRequestMissingStateThenInvalidRequestError() throws Exception { doFilterWhenUserConsentRequestInvalidParameterThenError( @@ -838,6 +995,154 @@ public void doFilterWhenUserConsentRequestApprovedThenAuthorizationResponse() th .isEqualTo(registeredClient.getScopes()); } + @Test + public void doFilterWhenUserConsentRequestApprovedThenSaveConsent() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.authentication.getName()) + .attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) + .build(); + when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + + MockHttpServletRequest request = createUserConsentRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + ArgumentCaptor consentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class); + + verify(this.consentService).save(consentCaptor.capture()); + OAuth2AuthorizationConsent consent = consentCaptor.getValue(); + assertThat(consent.getPrincipalName()).isEqualTo(this.authentication.getName()); + assertThat(consent.getRegisteredClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(consent.getScopes()).containsExactlyInAnyOrder("message.read", "message.write"); + } + + @Test + public void doFilterWhenUserConsentRequestApprovedAndNoScopesThenConsentNotSaved() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(Set::clear) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.authentication.getName()) + .attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) + .build(); + when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + + MockHttpServletRequest request = createUserConsentRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.consentService, never()).save(any()); + } + + @Test + public void doFilterWhenUserConsentRequestApprovedAndPreviousConsentExistsThenUpdatesConsent() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add("message.read"); + scopes.add("message.write"); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.authentication.getName()) + .attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) + .build(); + when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + OAuth2AuthorizationConsent previousConsent = + createConsent( + registeredClient.getClientId(), + this.authentication.getName(), + Collections.singleton("message.read") + ); + when(this.consentService.findById( + eq(registeredClient.getClientId()), + eq(this.authentication.getName()))) + .thenReturn(previousConsent); + + MockHttpServletRequest request = createUserConsentRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + ArgumentCaptor consentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class); + + verify(this.consentService).save(consentCaptor.capture()); + OAuth2AuthorizationConsent consent = consentCaptor.getValue(); + assertThat(consent.getPrincipalName()).isEqualTo(this.authentication.getName()); + assertThat(consent.getRegisteredClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(consent.getScopes()).containsExactlyInAnyOrder("message.read", "message.write"); + } + + @Test + public void doFilterWhenUserConsentRequestApprovedAndPreviousConsentExistsThenSavesOAuth2Authorization() throws Exception { + String newScope = "message.write"; + String previouslyApprovedScope = "message.read"; + String unrelatedPreviouslyApprovedScope = "unrelated.scope"; + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add(previouslyApprovedScope); + scopes.add(newScope); + }) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.authentication.getName()) + .attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) + .build(); + when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) + .thenReturn(registeredClient); + OAuth2AuthorizationConsent previousConsent = + createConsent( + registeredClient.getClientId(), + this.authentication.getName(), + Arrays.asList(previouslyApprovedScope, unrelatedPreviouslyApprovedScope) + ); + when(this.consentService.findById( + eq(registeredClient.getClientId()), + eq(this.authentication.getName()))) + .thenReturn(previousConsent); + + MockHttpServletRequest request = createUserConsentRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + + verify(this.authorizationService).save(authorizationCaptor.capture()); + Set savedAuthorizationScopes = authorizationCaptor.getValue().getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); + assertThat(savedAuthorizationScopes).containsExactlyInAnyOrder(newScope, previouslyApprovedScope); + assertThat(savedAuthorizationScopes).doesNotContain(unrelatedPreviouslyApprovedScope); + } + // gh-243 @Test public void doFilterWhenAuthorizationRequestIPv4LoopbackRedirectUriAndDifferentPortThenAuthorizationResponse() @@ -1008,6 +1313,20 @@ private static void addPkceParameters(MockHttpServletRequest request) { request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); } + private static OAuth2AuthorizationConsent createConsent( + String registeredClientId, + String prinicpalName, + Collection scopes + ) { + OAuth2AuthorizationConsent.Builder consentBuilder = OAuth2AuthorizationConsent + .withId(registeredClientId, prinicpalName); + for (String scope : scopes) { + consentBuilder.scope(scope); + } + return consentBuilder.build(); + + } + private static MockHttpServletRequest createUserConsentRequest(RegisteredClient registeredClient) { String requestUri = OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI; MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); @@ -1024,4 +1343,18 @@ private static MockHttpServletRequest createUserConsentRequest(RegisteredClient return request; } + + private static String scopeCheckbox(String scope) { + return MessageFormat.format( + "", + scope + ); + } + + private static String disabledScopeCheckbox(String scope) { + return MessageFormat.format( + "", + scope + ); + } } diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/spring-security-samples-boot-oauth2-integrated-authorizationserver-custom-consent-page.gradle b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/spring-security-samples-boot-oauth2-integrated-authorizationserver-custom-consent-page.gradle new file mode 100644 index 000000000..744b63d18 --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/spring-security-samples-boot-oauth2-integrated-authorizationserver-custom-consent-page.gradle @@ -0,0 +1,8 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile 'org.springframework.boot:spring-boot-starter-web' + compile 'org.springframework.boot:spring-boot-starter-thymeleaf' + compile 'org.springframework.boot:spring-boot-starter-security' + compile project(':spring-security-oauth2-authorization-server') +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/OAuth2AuthorizationServerCustomConsentPageApplication.java b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/OAuth2AuthorizationServerCustomConsentPageApplication.java new file mode 100644 index 000000000..2ea9a1b98 --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/OAuth2AuthorizationServerCustomConsentPageApplication.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Daniel Garnier-Moiroux + * @since 0.1.2 + */ +@SpringBootApplication +public class OAuth2AuthorizationServerCustomConsentPageApplication { + + public static void main(String[] args) { + SpringApplication.run(OAuth2AuthorizationServerCustomConsentPageApplication.class, args); + } + +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/AuthorizationServerConfig.java new file mode 100644 index 000000000..1022ebbbc --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/AuthorizationServerConfig.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.config; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.RequestMatcher; +import sample.jose.Jwks; + +import java.util.UUID; + +/** + * @author Joe Grandja + * @author Daniel Garnier-Moiroux + * @since 0.0.1 + */ +@Configuration(proxyBeanMethods = false) +public class AuthorizationServerConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer<>(); + authorizationServerConfigurer.consentPage("/consent"); + RequestMatcher endpointsMatcher = authorizationServerConfigurer + .getEndpointsMatcher(); + + http + .requestMatcher(endpointsMatcher) + .authorizeRequests(authorizeRequests -> + authorizeRequests.anyRequest().authenticated() + ) + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .apply(authorizationServerConfigurer); + return http.formLogin(Customizer.withDefaults()).build(); + } + + // @formatter:off + @Bean + public RegisteredClientRepository registeredClientRepository() { + RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("messaging-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") + .redirectUri("http://127.0.0.1:8080/authorized") + .scope(OidcScopes.OPENID) + .scope("message.read") + .scope("message.write") + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + return new InMemoryRegisteredClientRepository(registeredClient); + } + // @formatter:on + + @Bean + public JWKSource jwkSource() { + RSAKey rsaKey = Jwks.generateRsa(); + JWKSet jwkSet = new JWKSet(rsaKey); + return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + } + + @Bean + public ProviderSettings providerSettings() { + return new ProviderSettings().issuer("http://auth-server:9000"); + } + + @Bean + public OAuth2AuthorizationConsentService authorizationService() { + // Will be used by the ConsentController + return new InMemoryOAuth2AuthorizationConsentService(); + } +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/DefaultSecurityConfig.java b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/DefaultSecurityConfig.java new file mode 100644 index 000000000..9d5691977 --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/DefaultSecurityConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * @author Joe Grandja + * @since 0.1.0 + */ +@EnableWebSecurity +public class DefaultSecurityConfig { + + // formatter:off + @Bean + SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeRequests(authorizeRequests -> + authorizeRequests.anyRequest().authenticated() + ) + .formLogin(withDefaults()); + return http.build(); + } + // formatter:on + + // @formatter:off + @Bean + UserDetailsService users() { + UserDetails user = User.withDefaultPasswordEncoder() + .username("user1") + .password("password") + .roles("USER") + .build(); + return new InMemoryUserDetailsManager(user); + } + // @formatter:on + +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/Jwks.java b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/Jwks.java new file mode 100644 index 000000000..70dc6581a --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/Jwks.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.jose; + +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.RSAKey; + +import javax.crypto.SecretKey; +import java.security.KeyPair; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +/** + * @author Joe Grandja + * @since 0.1.0 + */ +public final class Jwks { + + private Jwks() { + } + + public static RSAKey generateRsa() { + KeyPair keyPair = KeyGeneratorUtils.generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // @formatter:off + return new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } + + public static ECKey generateEc() { + KeyPair keyPair = KeyGeneratorUtils.generateEcKey(); + ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); + ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate(); + Curve curve = Curve.forECParameterSpec(publicKey.getParams()); + // @formatter:off + return new ECKey.Builder(curve, publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } + + public static OctetSequenceKey generateSecret() { + SecretKey secretKey = KeyGeneratorUtils.generateSecretKey(); + // @formatter:off + return new OctetSequenceKey.Builder(secretKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/KeyGeneratorUtils.java b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/KeyGeneratorUtils.java new file mode 100644 index 000000000..3d8b911ff --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/KeyGeneratorUtils.java @@ -0,0 +1,84 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.jose; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECFieldFp; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.EllipticCurve; + +/** + * @author Joe Grandja + * @since 0.1.0 + */ +final class KeyGeneratorUtils { + + private KeyGeneratorUtils() { + } + + static SecretKey generateSecretKey() { + SecretKey hmacKey; + try { + hmacKey = KeyGenerator.getInstance("HmacSha256").generateKey(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return hmacKey; + } + + static KeyPair generateRsaKey() { + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + + static KeyPair generateEcKey() { + EllipticCurve ellipticCurve = new EllipticCurve( + new ECFieldFp( + new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951")), + new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"), + new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291")); + ECPoint ecPoint = new ECPoint( + new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"), + new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109")); + ECParameterSpec ecParameterSpec = new ECParameterSpec( + ellipticCurve, + ecPoint, + new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"), + 1); + + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(ecParameterSpec); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/web/ConsentController.java b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/web/ConsentController.java new file mode 100644 index 000000000..4703b44ab --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/web/ConsentController.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.web; + +import java.security.Principal; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * @author Daniel Garnier-Moiroux + * @since 0.1.2 + */ +@Controller +public class ConsentController { + + private final OAuth2AuthorizationConsentService authorizationConsentService; + + public ConsentController(OAuth2AuthorizationConsentService authorizationConsentService) { + this.authorizationConsentService = authorizationConsentService; + } + + @GetMapping(value = "/consent") + public String consent( + Principal principal, + Model model, + @RequestParam(OAuth2ParameterNames.SCOPE) String scope, + @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId, + @RequestParam(OAuth2ParameterNames.STATE) String state + ) { + // Remove scopes that were already approved + Set scopesToApprove = new HashSet<>(); + Set previouslyApprovedScopes = new HashSet<>(); + OAuth2AuthorizationConsent previousConsent = this.authorizationConsentService.findById(clientId, principal.getName()); + for (String scopeFromRequest : StringUtils.delimitedListToStringArray(scope, " ")) { + if (previousConsent != null && previousConsent.getScopes().contains(scopeFromRequest)) { + previouslyApprovedScopes.add(scopeFromRequest); + } else { + scopesToApprove.add(scopeFromRequest); + } + } + + model.addAttribute("state", state); + model.addAttribute("clientId", clientId); + model.addAttribute("scopes", withDescription(scopesToApprove)); + model.addAttribute("previouslyApprovedScopes", withDescription(previouslyApprovedScopes)); + model.addAttribute("principalName", principal.getName()); + + return "consent"; + } + + private Set withDescription(Set scopes) { + return scopes + .stream() + .map(ScopeWithDescription::new) + .collect(Collectors.toSet()); + } + + private static class ScopeWithDescription { + public final String scope; + public final String description; + + private final static String DEFAULT_DESCRIPTION = "UNKNOWN SCOPE - We cannot provide information about this permission, use caution when granting this."; + private static final Map scopeDescriptions = new HashMap<>(); + static { + scopeDescriptions.put( + "message.read", + "This application will be able to read your message." + ); + scopeDescriptions.put( + "message.write", + "This application will be able to add new messages. It will also be able to edit and delete existing messages." + ); + scopeDescriptions.put( + "other.scope", + "This is another scope example of a scope description." + ); + } + + ScopeWithDescription(String scope) { + this.scope = scope; + this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION); + } + } +} diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/application.yml b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/application.yml new file mode 100644 index 000000000..5e879a67f --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/application.yml @@ -0,0 +1,10 @@ +server: + port: 9000 + +logging: + level: + root: INFO + org.springframework.web: INFO + org.springframework.security: INFO + org.springframework.security.oauth2: INFO +# org.springframework.boot.autoconfigure: DEBUG diff --git a/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/templates/consent.html b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/templates/consent.html new file mode 100644 index 000000000..fc83f97de --- /dev/null +++ b/samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/templates/consent.html @@ -0,0 +1,87 @@ + + + + + + + Consent required + + + +
+
+

App permissions

+
+
+
+

+ The application + + wants to access your account + +

+
+
+
+

The following permissions are requested by the above app.
Please review + these and consent if you approve.

+
+
+
+
+ + + +
+ + +

+
+ +

You have already granted the following permissions to the above app:

+
+ + +

+
+ +
+ +
+
+ +
+
+
+
+
+
+

+ + Your consent to provide access is required. +
If you do not approve, click Cancel, in which case no information will be shared with the app. +
+

+
+
+
+ +