Remember user consent and make consent page configurable
Closes gh-283
This commit is contained in:
committed by
Joe Grandja
parent
4688b0f879
commit
683dad1443
@@ -36,9 +36,11 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
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<B extends HttpSecurityBui
|
||||
this.oidcProviderConfigurationEndpointMatcher.matches(request) ||
|
||||
this.authorizationServerMetadataEndpointMatcher.matches(request) ||
|
||||
this.oidcClientRegistrationEndpointMatcher.matches(request);
|
||||
private String consentPage;
|
||||
|
||||
/**
|
||||
* Sets the repository of registered clients.
|
||||
@@ -132,6 +135,18 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the authorization consent service.
|
||||
*
|
||||
* @param authorizationConsentService the authorization service
|
||||
* @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2AuthorizationServerConfigurer<B> 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 final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
|
||||
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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code client_id} the client identifier</li>
|
||||
* <li>{@code scope} the space separated list of scopes present in the authorization request</li>
|
||||
* <li>{@code state} a CSRF protection token</li>
|
||||
* </ul>
|
||||
*
|
||||
* In general, the consent page should create a form that submits
|
||||
* a request with the following requirements:
|
||||
*
|
||||
* <ul>
|
||||
* <li>It must be an HTTP POST</li>
|
||||
* <li>It must be submitted to {@link ProviderSettings#authorizationEndpoint()}</li>
|
||||
* <li>It must include the received {@code client_id} as an HTTP parameter</li>
|
||||
* <li>It must include the received {@code state} as an HTTP parameter</li>
|
||||
* <li>It must include the list of {@code scope}s the {@code Resource Owners}
|
||||
* consents to as an HTTP parameter</li>
|
||||
* <li>It must include the {@code consent_action} parameter, with value either
|
||||
* {@code approve} or {@code cancel} as an HTTP parameter</li>
|
||||
* </ul>
|
||||
*
|
||||
*
|
||||
* @param consentPage the consent page to redirect to if consent is required (e.g. "/consent")
|
||||
* @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2AuthorizationServerConfigurer<B> consentPage(String consentPage) {
|
||||
this.consentPage = consentPage;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@link RequestMatcher} for the authorization server endpoints.
|
||||
*
|
||||
@@ -263,7 +315,12 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
|
||||
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 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
|
||||
return authorizationService;
|
||||
}
|
||||
|
||||
private static <B extends HttpSecurityBuilder<B>> 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 <B extends HttpSecurityBuilder<B>> JwtEncoder getJwtEncoder(B builder) {
|
||||
JwtEncoder jwtEncoder = builder.getSharedObject(JwtEncoder.class);
|
||||
if (jwtEncoder == null) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* <b>NOTE:</b> 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<Integer, OAuth2AuthorizationConsent> 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<OAuth2AuthorizationConsent> 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());
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
* <p>
|
||||
* 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<GrantedAuthority> authorities;
|
||||
|
||||
private OAuth2AuthorizationConsent(String registeredClientId, String principalName, Set<GrantedAuthority> 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<GrantedAuthority> 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<String> 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<GrantedAuthority> authorities = new HashSet<>();
|
||||
|
||||
private Builder(String registeredClientId, String principalName) {
|
||||
this(registeredClientId, principalName, Collections.emptySet());
|
||||
}
|
||||
|
||||
private Builder(String registeredClientId, String principalName, Set<GrantedAuthority> 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<Set<GrantedAuthority>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -50,8 +50,11 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp
|
||||
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 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||
* @since 0.0.1
|
||||
* @see RegisteredClientRepository
|
||||
* @see OAuth2AuthorizationService
|
||||
* @see OAuth2AuthorizationConsentService
|
||||
* @see OAuth2Authorization
|
||||
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-4.1">Section 4.1 Authorization Code Grant</a>
|
||||
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-4.1.1">Section 4.1.1 Authorization Request</a>
|
||||
@@ -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 class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
* @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 class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
.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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
|
||||
// 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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
// 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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
|
||||
private static Set<String> extractScopes(MultiValueMap<String, String> parameters) {
|
||||
List<String> 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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
private static String generateConsentPage(HttpServletRequest request,
|
||||
RegisteredClient registeredClient, OAuth2Authorization authorization) {
|
||||
|
||||
RegisteredClient registeredClient, OAuth2Authorization authorization, OAuth2AuthorizationConsent previousConsent) {
|
||||
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
|
||||
OAuth2AuthorizationRequest.class.getName());
|
||||
Set<String> scopes = new HashSet<>(authorizationRequest.getScopes());
|
||||
scopes.remove(OidcScopes.OPENID); // openid scope does not require consent
|
||||
String state = authorization.getAttribute(
|
||||
OAuth2ParameterNames.STATE);
|
||||
|
||||
Set<String> scopes = new HashSet<>();
|
||||
Set<String> 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 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
builder.append(" </div>");
|
||||
}
|
||||
|
||||
if (!previouslyApprovedScopes.isEmpty()) {
|
||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||
for (String scope : previouslyApprovedScopes) {
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
}
|
||||
|
||||
builder.append(" <div class=\"form-group pt-3\">");
|
||||
builder.append(" <button class=\"btn btn-primary btn-lg\" type=\"submit\" name=\"consent_action\" value=\"approve\">Submit Consent</button>");
|
||||
builder.append(" </div>");
|
||||
|
||||
@@ -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.HttpStatus;
|
||||
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.client.TestRegis
|
||||
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<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter =
|
||||
new OAuth2AccessTokenResponseHttpMessageConverter();
|
||||
private static String consentPage = "/custom-consent";
|
||||
|
||||
@Rule
|
||||
public final SpringTestRule spring = new SpringTestRule();
|
||||
@@ -237,11 +245,9 @@ public class OAuth2AuthorizationCodeGrantTests {
|
||||
|
||||
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 class OAuth2AuthorizationCodeGrantTests {
|
||||
.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 class OAuth2AuthorizationCodeGrantTests {
|
||||
|
||||
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<OAuth2Authorization> 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<String, String> 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<OAuth2Authorization> 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<String, String> getAuthorizationRequestParameters(RegisteredClient registeredClient) {
|
||||
@@ -350,12 +478,21 @@ public class OAuth2AuthorizationCodeGrantTests {
|
||||
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(
|
||||
"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"{0}\" id=\"{0}\" checked>",
|
||||
scope
|
||||
);
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
@@ -418,4 +555,25 @@ public class OAuth2AuthorizationCodeGrantTests {
|
||||
}
|
||||
}
|
||||
|
||||
@EnableWebSecurity
|
||||
static class AuthorizationServerConfigurationCustomConsentPage extends AuthorizationServerConfiguration {
|
||||
// @formatter:off
|
||||
@Bean
|
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
OAuth2AuthorizationServerConfigurer<HttpSecurity> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OAuth2AuthorizationConsent>) 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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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.After;
|
||||
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.OAuth2ParameterNames;
|
||||
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 class OAuth2AuthorizationEndpointFilterTests {
|
||||
|
||||
@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 class OAuth2AuthorizationEndpointFilterTests {
|
||||
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 class OAuth2AuthorizationEndpointFilterTests {
|
||||
}
|
||||
|
||||
@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 class OAuth2AuthorizationEndpointFilterTests {
|
||||
|
||||
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<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
|
||||
|
||||
verify(this.authorizationService).save(authorizationCaptor.capture());
|
||||
@@ -617,6 +630,150 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
||||
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<OAuth2Authorization> 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 class OAuth2AuthorizationEndpointFilterTests {
|
||||
.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<OAuth2AuthorizationConsent> 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<OAuth2AuthorizationConsent> 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<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
|
||||
|
||||
verify(this.authorizationService).save(authorizationCaptor.capture());
|
||||
Set<String> 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 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
||||
request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
|
||||
}
|
||||
|
||||
private static OAuth2AuthorizationConsent createConsent(
|
||||
String registeredClientId,
|
||||
String prinicpalName,
|
||||
Collection<String> 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 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static String scopeCheckbox(String scope) {
|
||||
return MessageFormat.format(
|
||||
"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"{0}\" id=\"{0}\" checked>",
|
||||
scope
|
||||
);
|
||||
}
|
||||
|
||||
private static String disabledScopeCheckbox(String scope) {
|
||||
return MessageFormat.format(
|
||||
"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"{0}\" checked disabled>",
|
||||
scope
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<HttpSecurity> 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<SecurityContext> 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> scopesToApprove = new HashSet<>();
|
||||
Set<String> 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<ScopeWithDescription> withDescription(Set<String> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
|
||||
<title>Consent required</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: aliceblue;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="py-5">
|
||||
<h1 class="text-center text-primary">App permissions</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<p>
|
||||
The application
|
||||
<span class="font-weight-bold text-primary" th:text="${clientId}"></span>
|
||||
wants to access your account
|
||||
<span class="font-weight-bold" th:text="${principalName}"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-center"><p>The following permissions are requested by the above app.<br/>Please review
|
||||
these and consent if you approve.</p></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<form method="post" action="/oauth2/authorize">
|
||||
<input type="hidden" name="client_id" th:value="${clientId}">
|
||||
<input type="hidden" name="state" th:value="${state}">
|
||||
|
||||
<div th:each="scope: ${scopes}" class="form-group form-check py-1">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
name="scope"
|
||||
th:value="${scope.scope}"
|
||||
th:id="${scope.scope}"
|
||||
checked>
|
||||
<label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label>
|
||||
<p class="text-primary" th:text="${scope.description}"></p>
|
||||
</div>
|
||||
|
||||
<p th:if="${not #lists.isEmpty(previouslyApprovedScopes)}">You have already granted the following permissions to the above app:</p>
|
||||
<div th:each="scope: ${previouslyApprovedScopes}" class="form-group form-check py-1">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
th:id="${scope.scope}"
|
||||
disabled
|
||||
checked>
|
||||
<label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label>
|
||||
<p class="text-primary" th:text="${scope.description}"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-group pt-3">
|
||||
<button class="btn btn-primary btn-lg" type="submit" name="consent_action" value="approve">
|
||||
Submit Consent
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-link regular" type="submit" name="consent_action" value="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pt-4">
|
||||
<div class="col text-center">
|
||||
<p>
|
||||
<small>
|
||||
Your consent to provide access is required.
|
||||
<br/>If you do not approve, click Cancel, in which case no information will be shared with the app.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user