Add support for OAuth 2.0 Device Authorization Grant
Closes gh-44
This commit is contained in:
committed by
Steve Riesenberg
parent
ad01779479
commit
291ba8c92d
@@ -24,7 +24,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||
@@ -164,6 +166,10 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
|
||||
return matchesIdToken(authorization, token);
|
||||
} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
|
||||
return matchesRefreshToken(authorization, token);
|
||||
} else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) {
|
||||
return matchesDeviceCode(authorization, token);
|
||||
} else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) {
|
||||
return matchesUserCode(authorization, token);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -196,6 +202,18 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
|
||||
return idToken != null && idToken.getToken().getTokenValue().equals(token);
|
||||
}
|
||||
|
||||
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode =
|
||||
authorization.getToken(OAuth2DeviceCode.class);
|
||||
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
|
||||
}
|
||||
|
||||
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2UserCode> userCode =
|
||||
authorization.getToken(OAuth2UserCode.class);
|
||||
return userCode != null && userCode.getToken().getTokenValue().equals(token);
|
||||
}
|
||||
|
||||
private static final class MaxSizeHashMap<K, V> extends LinkedHashMap<K, V> {
|
||||
private final int maxSize;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -253,6 +253,18 @@ public class OAuth2Authorization implements Serializable {
|
||||
*/
|
||||
public static final String INVALIDATED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("invalidated");
|
||||
|
||||
/**
|
||||
* The name of the metadata that indicates if access has been denied by the resource owner.
|
||||
* Used with the OAuth 2.0 Device Authorization Grant.
|
||||
*/
|
||||
public static final String ACCESS_DENIED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_denied");
|
||||
|
||||
/**
|
||||
* The name of the metadata that indicates if access has been denied by the resource owner.
|
||||
* Used with the OAuth 2.0 Device Authorization Grant.
|
||||
*/
|
||||
public static final String ACCESS_GRANTED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_granted");
|
||||
|
||||
/**
|
||||
* The name of the metadata used for the claims of the token.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -113,6 +113,10 @@ public final class OAuth2AuthorizationConsentAuthenticationContext implements OA
|
||||
super(authentication);
|
||||
}
|
||||
|
||||
private Builder(OAuth2DeviceAuthorizationConsentAuthenticationToken authentication) {
|
||||
super(authentication);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link OAuth2AuthorizationConsent.Builder authorization consent builder}.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
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.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} implementation for the OAuth 2.0 Authorization Consent
|
||||
* used in the Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationToken
|
||||
* @see OAuth2AuthorizationConsent
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationProvider
|
||||
* @see OAuth2DeviceVerificationAuthenticationProvider
|
||||
* @see OAuth2DeviceCodeAuthenticationProvider
|
||||
* @see RegisteredClientRepository
|
||||
* @see OAuth2AuthorizationService
|
||||
* @see OAuth2AuthorizationConsentService
|
||||
*/
|
||||
public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
|
||||
private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
private final OAuth2AuthorizationConsentService authorizationConsentService;
|
||||
private Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationProvider} using the provided parameters.
|
||||
*
|
||||
* @param registeredClientRepository the repository of registered clients
|
||||
* @param authorizationService the authorization service
|
||||
* @param authorizationConsentService the authorization consent service
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationConsentAuthenticationProvider(
|
||||
RegisteredClientRepository registeredClientRepository,
|
||||
OAuth2AuthorizationService authorizationService,
|
||||
OAuth2AuthorizationConsentService authorizationConsentService) {
|
||||
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
|
||||
Assert.notNull(authorizationService, "authorizationService cannot be null");
|
||||
Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null");
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
this.authorizationService = authorizationService;
|
||||
this.authorizationConsentService = authorizationConsentService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication =
|
||||
(OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication;
|
||||
|
||||
OAuth2Authorization authorization = this.authorizationService.findByToken(
|
||||
deviceAuthorizationConsentAuthentication.getState(), STATE_TOKEN_TYPE);
|
||||
if (authorization == null) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved authorization with device authorization consent state");
|
||||
}
|
||||
|
||||
Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal();
|
||||
|
||||
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(
|
||||
deviceAuthorizationConsentAuthentication.getClientId());
|
||||
if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved registered client");
|
||||
}
|
||||
|
||||
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
|
||||
OAuth2AuthorizationRequest.class.getName());
|
||||
Set<String> requestedScopes = authorizationRequest.getScopes();
|
||||
Set<String> authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ?
|
||||
new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) :
|
||||
new HashSet<>();
|
||||
if (!requestedScopes.containsAll(authorizedScopes)) {
|
||||
throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Validated device authorization consent request parameters");
|
||||
}
|
||||
|
||||
OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById(
|
||||
authorization.getRegisteredClientId(), principal.getName());
|
||||
Set<String> currentAuthorizedScopes = currentAuthorizationConsent != null ?
|
||||
currentAuthorizationConsent.getScopes() : Collections.emptySet();
|
||||
|
||||
if (!currentAuthorizedScopes.isEmpty()) {
|
||||
for (String requestedScope : requestedScopes) {
|
||||
if (currentAuthorizedScopes.contains(requestedScope)) {
|
||||
authorizedScopes.add(requestedScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OAuth2AuthorizationConsent.Builder authorizationConsentBuilder;
|
||||
if (currentAuthorizationConsent != null) {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved existing authorization consent");
|
||||
}
|
||||
authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent);
|
||||
} else {
|
||||
authorizationConsentBuilder = OAuth2AuthorizationConsent.withId(
|
||||
authorization.getRegisteredClientId(), principal.getName());
|
||||
}
|
||||
authorizedScopes.forEach(authorizationConsentBuilder::scope);
|
||||
|
||||
if (this.authorizationConsentCustomizer != null) {
|
||||
// @formatter:off
|
||||
OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext =
|
||||
OAuth2AuthorizationConsentAuthenticationContext.with(deviceAuthorizationConsentAuthentication)
|
||||
.authorizationConsent(authorizationConsentBuilder)
|
||||
.registeredClient(registeredClient)
|
||||
.authorization(authorization)
|
||||
.authorizationRequest(authorizationRequest)
|
||||
.build();
|
||||
// @formatter:on
|
||||
this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext);
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Customized authorization consent");
|
||||
}
|
||||
}
|
||||
|
||||
Set<GrantedAuthority> authorities = new HashSet<>();
|
||||
authorizationConsentBuilder.authorities(authorities::addAll);
|
||||
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class);
|
||||
OAuth2Authorization.Token<OAuth2UserCode> userCodeToken = authorization.getToken(OAuth2UserCode.class);
|
||||
|
||||
if (authorities.isEmpty()) {
|
||||
// Authorization consent denied (or revoked)
|
||||
if (currentAuthorizationConsent != null) {
|
||||
this.authorizationConsentService.remove(currentAuthorizationConsent);
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Revoked authorization consent");
|
||||
}
|
||||
}
|
||||
authorization = OAuth2Authorization.from(authorization)
|
||||
.token(deviceCodeToken.getToken(), metadata ->
|
||||
metadata.put(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME, true))
|
||||
.token(userCodeToken.getToken(), metadata ->
|
||||
metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Invalidated device code and user code because authorization consent was denied");
|
||||
}
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED);
|
||||
}
|
||||
|
||||
OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build();
|
||||
if (!authorizationConsent.equals(currentAuthorizationConsent)) {
|
||||
this.authorizationConsentService.save(authorizationConsent);
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Saved authorization consent");
|
||||
}
|
||||
}
|
||||
|
||||
OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
|
||||
.principalName(principal.getName())
|
||||
.authorizedScopes(authorizedScopes)
|
||||
.token(deviceCodeToken.getToken(), metadata -> metadata
|
||||
.put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true))
|
||||
.token(userCodeToken.getToken(), metadata -> metadata
|
||||
.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
|
||||
.attribute(Principal.class.getName(), principal)
|
||||
.attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE))
|
||||
.build();
|
||||
this.authorizationService.save(updatedAuthorization);
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Saved authorization with authorized scopes");
|
||||
// This log is kept separate for consistency with other providers
|
||||
this.logger.trace("Authenticated authorization consent request");
|
||||
}
|
||||
|
||||
return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal,
|
||||
deviceAuthorizationConsentAuthentication.getUserCode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return OAuth2DeviceAuthorizationConsentAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationConsentAuthenticationContext}
|
||||
* containing an {@link OAuth2AuthorizationConsent.Builder} and additional context information.
|
||||
*
|
||||
* <p>
|
||||
* The following context attributes are available:
|
||||
* <ul>
|
||||
* <li>The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent
|
||||
* prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.</li>
|
||||
* <li>The {@link Authentication} of type
|
||||
* {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.</li>
|
||||
* <li>The {@link RegisteredClient} associated with the authorization request.</li>
|
||||
* <li>The {@link OAuth2Authorization} associated with the state token presented in the
|
||||
* authorization consent request.</li>
|
||||
* <li>The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param authorizationConsentCustomizer the {@code Consumer} providing access to the
|
||||
* {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder}
|
||||
*/
|
||||
public void setAuthorizationConsentCustomizer(Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer) {
|
||||
Assert.notNull(authorizationConsentCustomizer, "authorizationConsentCustomizer cannot be null");
|
||||
this.authorizationConsentCustomizer = authorizationConsentCustomizer;
|
||||
}
|
||||
|
||||
private static void throwError(String errorCode, String parameterName) {
|
||||
OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, DEFAULT_ERROR_URI);
|
||||
throw new OAuth2AuthorizationException(error);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link Authentication} implementation for the Authorization Consent used
|
||||
* in the OAuth 2.0 Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
public class OAuth2DeviceAuthorizationConsentAuthenticationToken extends OAuth2AuthorizationConsentAuthenticationToken {
|
||||
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
|
||||
private final String userCode;
|
||||
private final Set<String> requestedScopes;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param authorizationUri the authorization URI
|
||||
* @param clientId the client identifier
|
||||
* @param principal the {@code Principal} (Resource Owner)
|
||||
* @param userCode the user code associated with the device authorization request
|
||||
* @param state the state
|
||||
* @param authorizedScopes the authorized scope(s)
|
||||
* @param additionalParameters the additional parameters
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationConsentAuthenticationToken(String authorizationUri, String clientId,
|
||||
Authentication principal, String userCode, String state, @Nullable Set<String> authorizedScopes,
|
||||
@Nullable Map<String, Object> additionalParameters) {
|
||||
super(authorizationUri, clientId, principal, state, authorizedScopes, additionalParameters);
|
||||
Assert.hasText(userCode, "userCode cannot be empty");
|
||||
this.userCode = userCode;
|
||||
this.requestedScopes = null;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param authorizationUri the authorization URI
|
||||
* @param clientId the client identifier
|
||||
* @param principal the {@code Principal} (Resource Owner)
|
||||
* @param userCode the user code associated with the device authorization request
|
||||
* @param state the state
|
||||
* @param requestedScopes the requested scope(s)
|
||||
* @param authorizedScopes the authorized scope(s)
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationConsentAuthenticationToken(String authorizationUri, String clientId,
|
||||
Authentication principal, String userCode, String state, @Nullable Set<String> requestedScopes,
|
||||
@Nullable Set<String> authorizedScopes) {
|
||||
super(authorizationUri, clientId, principal, state, authorizedScopes, null);
|
||||
Assert.hasText(userCode, "userCode cannot be empty");
|
||||
this.userCode = userCode;
|
||||
this.requestedScopes = Collections.unmodifiableSet(
|
||||
requestedScopes != null ?
|
||||
new HashSet<>(requestedScopes) :
|
||||
Collections.emptySet());
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user code.
|
||||
*
|
||||
* @return the user code
|
||||
*/
|
||||
public String getUserCode() {
|
||||
return this.userCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the requested scopes.
|
||||
*
|
||||
* @return the requested scopes
|
||||
*/
|
||||
public Set<String> getRequestedScopes() {
|
||||
return this.requestedScopes;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.keygen.StringKeyGenerator;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
|
||||
import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} implementation for the Device Authorization Request
|
||||
* used in the OAuth 2.0 Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationToken
|
||||
* @see OAuth2DeviceVerificationAuthenticationProvider
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationProvider
|
||||
* @see OAuth2DeviceCodeAuthenticationProvider
|
||||
* @see OAuth2AuthorizationService
|
||||
* @see OAuth2TokenGenerator
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.1">Section 3.1 Device Authorization Request</a>
|
||||
*/
|
||||
public final class OAuth2DeviceAuthorizationRequestAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
|
||||
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
|
||||
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
private OAuth2TokenGenerator<OAuth2DeviceCode> deviceCodeGenerator = new OAuth2DeviceCodeGenerator();
|
||||
private OAuth2TokenGenerator<OAuth2UserCode> userCodeGenerator = new OAuth2UserCodeGenerator();
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationProvider} using the provided parameters.
|
||||
*
|
||||
* @param authorizationService the authorization service
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationRequestAuthenticationProvider(OAuth2AuthorizationService authorizationService) {
|
||||
Assert.notNull(authorizationService, "authorizationService cannot be null");
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication =
|
||||
(OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication;
|
||||
|
||||
OAuth2ClientAuthenticationToken clientPrincipal =
|
||||
getAuthenticatedClientElseThrowInvalidClient(deviceAuthorizationRequestAuthentication);
|
||||
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved registered client");
|
||||
}
|
||||
|
||||
// Validate client grant types has device_code grant type
|
||||
if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.DEVICE_CODE)) {
|
||||
throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Validated device authorization request parameters");
|
||||
}
|
||||
|
||||
// @formatter:off
|
||||
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
|
||||
.registeredClient(registeredClient)
|
||||
.principal(clientPrincipal)
|
||||
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
|
||||
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
|
||||
.authorizationGrant(deviceAuthorizationRequestAuthentication);
|
||||
// @formatter:on
|
||||
|
||||
// Generate a high-entropy string to use as the device code
|
||||
OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(DEVICE_CODE_TOKEN_TYPE).build();
|
||||
OAuth2DeviceCode deviceCode = this.deviceCodeGenerator.generate(tokenContext);
|
||||
if (deviceCode == null) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
|
||||
"The token generator failed to generate the device code.", ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
logger.trace("Generated device code");
|
||||
}
|
||||
|
||||
// Generate a low-entropy string to use as the user code
|
||||
tokenContext = tokenContextBuilder.tokenType(USER_CODE_TOKEN_TYPE).build();
|
||||
OAuth2UserCode userCode = this.userCodeGenerator.generate(tokenContext);
|
||||
if (userCode == null) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
|
||||
"The token generator failed to generate the user code.", ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
logger.trace("Generated user code");
|
||||
}
|
||||
|
||||
String authorizationUri = deviceAuthorizationRequestAuthentication.getAuthorizationUri();
|
||||
|
||||
Set<String> requestedScopes = deviceAuthorizationRequestAuthentication.getScopes();
|
||||
|
||||
// @formatter:off
|
||||
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
|
||||
.authorizationUri(authorizationUri)
|
||||
.clientId(registeredClient.getClientId())
|
||||
.scopes(requestedScopes)
|
||||
.build();
|
||||
// @formatter:on
|
||||
|
||||
// @formatter:off
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
|
||||
.principalName(clientPrincipal.getName())
|
||||
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
|
||||
.token(deviceCode)
|
||||
.token(userCode)
|
||||
.attribute(Principal.class.getName(), clientPrincipal)
|
||||
.attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest)
|
||||
.build();
|
||||
// @formatter:on
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Saved authorization");
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Authenticated device authorization request");
|
||||
}
|
||||
|
||||
return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, requestedScopes, deviceCode, userCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return OAuth2DeviceAuthorizationRequestAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link OAuth2TokenGenerator} that generates the {@link OAuth2DeviceCode}.
|
||||
*
|
||||
* @param deviceCodeGenerator the {@link OAuth2TokenGenerator} that generates the {@link OAuth2DeviceCode}
|
||||
*/
|
||||
public void setDeviceCodeGenerator(OAuth2TokenGenerator<OAuth2DeviceCode> deviceCodeGenerator) {
|
||||
Assert.notNull(deviceCodeGenerator, "deviceCodeGenerator cannot be null");
|
||||
this.deviceCodeGenerator = deviceCodeGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link OAuth2TokenGenerator} that generates the {@link OAuth2UserCode}.
|
||||
*
|
||||
* @param userCodeGenerator the {@link OAuth2TokenGenerator} that generates the {@link OAuth2UserCode}
|
||||
*/
|
||||
public void setUserCodeGenerator(OAuth2TokenGenerator<OAuth2UserCode> userCodeGenerator) {
|
||||
Assert.notNull(userCodeGenerator, "userCodeGenerator cannot be null");
|
||||
this.userCodeGenerator = userCodeGenerator;
|
||||
}
|
||||
|
||||
private static void throwError(String errorCode, String parameterName) {
|
||||
OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
private static final class OAuth2DeviceCodeGenerator implements OAuth2TokenGenerator<OAuth2DeviceCode> {
|
||||
|
||||
private final StringKeyGenerator deviceCodeGenerator =
|
||||
new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public OAuth2DeviceCode generate(OAuth2TokenContext context) {
|
||||
if (context.getTokenType() == null ||
|
||||
!OAuth2ParameterNames.DEVICE_CODE.equals(context.getTokenType().getValue())) {
|
||||
return null;
|
||||
}
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getDeviceCodeTimeToLive());
|
||||
return new OAuth2DeviceCode(this.deviceCodeGenerator.generateKey(), issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class UserCodeStringKeyGenerator implements StringKeyGenerator {
|
||||
|
||||
// @formatter:off
|
||||
private static final char[] VALID_CHARS = {
|
||||
'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M',
|
||||
'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Z'
|
||||
};
|
||||
// @formatter:on
|
||||
|
||||
private final BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom(8);
|
||||
|
||||
@Override
|
||||
public String generateKey() {
|
||||
byte[] bytes = this.keyGenerator.generateKey();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
int offset = Math.abs(b % 20);
|
||||
sb.append(VALID_CHARS[offset]);
|
||||
}
|
||||
sb.insert(4, '-');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class OAuth2UserCodeGenerator implements OAuth2TokenGenerator<OAuth2UserCode> {
|
||||
|
||||
private final StringKeyGenerator userCodeGenerator = new UserCodeStringKeyGenerator();
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public OAuth2UserCode generate(OAuth2TokenContext context) {
|
||||
if (context.getTokenType() == null ||
|
||||
!OAuth2ParameterNames.USER_CODE.equals(context.getTokenType().getValue())) {
|
||||
return null;
|
||||
}
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getDeviceCodeTimeToLive());
|
||||
return new OAuth2UserCode(this.userCodeGenerator.generateKey(), issuedAt, expiresAt);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link Authentication} implementation for the OAuth 2.0 Device Authorization Request
|
||||
* used in the Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AbstractAuthenticationToken
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationProvider
|
||||
*/
|
||||
public class OAuth2DeviceAuthorizationRequestAuthenticationToken extends AbstractAuthenticationToken {
|
||||
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
|
||||
private final Authentication clientPrincipal;
|
||||
private final String authorizationUri;
|
||||
private final Set<String> scopes;
|
||||
private final OAuth2DeviceCode deviceCode;
|
||||
private final OAuth2UserCode userCode;
|
||||
private final Map<String, Object> additionalParameters;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param clientPrincipal the authenticated client principal
|
||||
* @param authorizationUri the authorization {@code URI}
|
||||
* @param scopes the requested scope(s)
|
||||
* @param additionalParameters the additional parameters
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication clientPrincipal, String authorizationUri,
|
||||
@Nullable Set<String> scopes, @Nullable Map<String, Object> additionalParameters) {
|
||||
super(Collections.emptyList());
|
||||
Assert.notNull(clientPrincipal, "clientPrincipal cannot be null");
|
||||
Assert.hasText(authorizationUri, "authorizationUri cannot be empty");
|
||||
this.clientPrincipal = clientPrincipal;
|
||||
this.authorizationUri = authorizationUri;
|
||||
this.scopes = Collections.unmodifiableSet(
|
||||
scopes != null ?
|
||||
new HashSet<>(scopes) :
|
||||
Collections.emptySet());
|
||||
this.additionalParameters = additionalParameters;
|
||||
this.deviceCode = null;
|
||||
this.userCode = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param clientPrincipal the authenticated client principal
|
||||
* @param scopes the requested scope(s)
|
||||
* @param deviceCode the {@link OAuth2DeviceCode}
|
||||
* @param userCode the {@link OAuth2UserCode}
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication clientPrincipal, @Nullable Set<String> scopes,
|
||||
OAuth2DeviceCode deviceCode, OAuth2UserCode userCode) {
|
||||
super(Collections.emptyList());
|
||||
Assert.notNull(clientPrincipal, "clientPrincipal cannot be null");
|
||||
Assert.notNull(deviceCode, "deviceCode cannot be null");
|
||||
Assert.notNull(userCode, "userCode cannot be null");
|
||||
this.clientPrincipal = clientPrincipal;
|
||||
this.scopes = Collections.unmodifiableSet(
|
||||
scopes != null ?
|
||||
new HashSet<>(scopes) :
|
||||
Collections.emptySet());
|
||||
this.deviceCode = deviceCode;
|
||||
this.userCode = userCode;
|
||||
this.authorizationUri = null;
|
||||
this.additionalParameters = null;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return this.clientPrincipal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authorization {@code URI}.
|
||||
*
|
||||
* @return the authorization {@code URI}.
|
||||
*/
|
||||
public String getAuthorizationUri() {
|
||||
return authorizationUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the requested scope(s).
|
||||
*
|
||||
* @return the requested scope(s).
|
||||
*/
|
||||
public Set<String> getScopes() {
|
||||
return this.scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device code.
|
||||
*
|
||||
* @return the device code
|
||||
*/
|
||||
public OAuth2DeviceCode getDeviceCode() {
|
||||
return this.deviceCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user code.
|
||||
*
|
||||
* @return the user code
|
||||
*/
|
||||
public OAuth2UserCode getUserCode() {
|
||||
return this.userCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the additional parameters.
|
||||
*
|
||||
* @return the additional parameters
|
||||
*/
|
||||
public Map<String, Object> getAdditionalParameters() {
|
||||
return this.additionalParameters;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
|
||||
import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} implementation for the OAuth 2.0 Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2DeviceCodeAuthenticationToken
|
||||
* @see OAuth2AccessTokenAuthenticationToken
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationProvider
|
||||
* @see OAuth2DeviceVerificationAuthenticationProvider
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationProvider
|
||||
* @see OAuth2AuthorizationService
|
||||
* @see OAuth2TokenGenerator
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.4">Section 3.4 Device Access Token Request</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.5">Section 3.5 Device Access Token Response</a>
|
||||
*/
|
||||
public final class OAuth2DeviceCodeAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
|
||||
private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.5";
|
||||
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceCodeAuthenticationProvider} using the provided parameters.
|
||||
*
|
||||
* @param authorizationService the authorization service
|
||||
* @param tokenGenerator the token generator
|
||||
*/
|
||||
public OAuth2DeviceCodeAuthenticationProvider(
|
||||
OAuth2AuthorizationService authorizationService,
|
||||
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {
|
||||
Assert.notNull(authorizationService, "authorizationService cannot be null");
|
||||
Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
|
||||
this.authorizationService = authorizationService;
|
||||
this.tokenGenerator = tokenGenerator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
OAuth2DeviceCodeAuthenticationToken deviceCodeAuthentication =
|
||||
(OAuth2DeviceCodeAuthenticationToken) authentication;
|
||||
|
||||
OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils
|
||||
.getAuthenticatedClientElseThrowInvalidClient(deviceCodeAuthentication);
|
||||
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved registered client");
|
||||
}
|
||||
|
||||
OAuth2Authorization authorization = this.authorizationService.findByToken(
|
||||
deviceCodeAuthentication.getDeviceCode(), DEVICE_CODE_TOKEN_TYPE);
|
||||
if (authorization == null) {
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved authorization with device code");
|
||||
}
|
||||
|
||||
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
|
||||
OAuth2AuthorizationRequest.class.getName());
|
||||
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
||||
|
||||
if (!registeredClient.getClientId().equals(authorizationRequest.getClientId())) {
|
||||
if (!deviceCode.isInvalidated()) {
|
||||
// Invalidate the device code given that a different client is attempting to use it
|
||||
authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, deviceCode.getToken());
|
||||
this.authorizationService.save(authorization);
|
||||
if (this.logger.isWarnEnabled()) {
|
||||
this.logger.warn(LogMessage.format(
|
||||
"Invalidated device code used by registered client '%s'", registeredClient.getId()));
|
||||
}
|
||||
}
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
|
||||
}
|
||||
|
||||
// In https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5,
|
||||
// the following error codes are defined:
|
||||
|
||||
// access_denied
|
||||
// The authorization request was denied.
|
||||
if (Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME))) {
|
||||
OAuth2Error error = new OAuth2Error("access_denied", null, DEVICE_ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
// expired_token
|
||||
// The "device_code" has expired, and the device authorization
|
||||
// session has concluded. The client MAY commence a new device
|
||||
// authorization request but SHOULD wait for user interaction before
|
||||
// restarting to avoid unnecessary polling.
|
||||
if (deviceCode.isExpired()) {
|
||||
OAuth2Error error = new OAuth2Error("expired_token", null, DEVICE_ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
// slow_down
|
||||
// A variant of "authorization_pending", the authorization request is
|
||||
// still pending and polling should continue, but the interval MUST
|
||||
// be increased by 5 seconds for this and all subsequent requests.
|
||||
// Note: This error is not handled in the framework.
|
||||
|
||||
// authorization_pending
|
||||
// The authorization request is still pending as the end user hasn't
|
||||
// yet completed the user-interaction steps (Section 3.3). The
|
||||
// client SHOULD repeat the access token request to the token
|
||||
// endpoint (a process known as polling). Before each new request,
|
||||
// the client MUST wait at least the number of seconds specified by
|
||||
// the "interval" parameter of the device authorization response (see
|
||||
// Section 3.2), or 5 seconds if none was provided, and respect any
|
||||
// increase in the polling interval required by the "slow_down"
|
||||
// error.
|
||||
if (!Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME))) {
|
||||
OAuth2Error error = new OAuth2Error("authorization_pending", null, DEVICE_ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
if (!deviceCode.isActive()) {
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Validated token request parameters");
|
||||
}
|
||||
|
||||
// @formatter:off
|
||||
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
|
||||
.registeredClient(registeredClient)
|
||||
.principal(authorization.getAttribute(Principal.class.getName()))
|
||||
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
|
||||
.authorization(authorization)
|
||||
.authorizedScopes(authorization.getAuthorizedScopes())
|
||||
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
|
||||
.authorizationGrant(deviceCodeAuthentication);
|
||||
// @formatter:on
|
||||
|
||||
// @formatter:off
|
||||
OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization)
|
||||
// Invalidate the device code as it can only be used (successfully) once
|
||||
.token(deviceCode.getToken(), metadata ->
|
||||
metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true));
|
||||
// @formatter:on
|
||||
|
||||
// ----- Access token -----
|
||||
OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build();
|
||||
OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
|
||||
if (generatedAccessToken == null) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
|
||||
"The token generator failed to generate the access token.", DEFAULT_ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Generated access token");
|
||||
}
|
||||
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
|
||||
generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
|
||||
generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());
|
||||
if (generatedAccessToken instanceof ClaimAccessor) {
|
||||
authorizationBuilder.token(accessToken, (metadata) ->
|
||||
metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));
|
||||
} else {
|
||||
authorizationBuilder.accessToken(accessToken);
|
||||
}
|
||||
|
||||
// ----- Refresh token -----
|
||||
OAuth2RefreshToken refreshToken = null;
|
||||
if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) &&
|
||||
// Do not issue refresh token to public client
|
||||
!clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {
|
||||
|
||||
tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();
|
||||
OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);
|
||||
if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
|
||||
"The token generator failed to generate the refresh token.", DEFAULT_ERROR_URI);
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Generated refresh token");
|
||||
}
|
||||
|
||||
refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
|
||||
authorizationBuilder.refreshToken(refreshToken);
|
||||
}
|
||||
|
||||
authorization = authorizationBuilder.build();
|
||||
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Saved authorization");
|
||||
}
|
||||
|
||||
return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return OAuth2DeviceCodeAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link Authentication} implementation used for the OAuth 2.0 Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2AuthorizationGrantAuthenticationToken
|
||||
* @see OAuth2DeviceCodeAuthenticationProvider
|
||||
*/
|
||||
public class OAuth2DeviceCodeAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {
|
||||
|
||||
private final String deviceCode;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceCodeAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param deviceCode the device code
|
||||
* @param clientPrincipal the authenticated client principal
|
||||
* @param additionalParameters the additional parameters
|
||||
*/
|
||||
public OAuth2DeviceCodeAuthenticationToken(String deviceCode, Authentication clientPrincipal, @Nullable Map<String, Object> additionalParameters) {
|
||||
super(AuthorizationGrantType.DEVICE_CODE, clientPrincipal, additionalParameters);
|
||||
Assert.hasText(deviceCode, "deviceCode cannot be empty");
|
||||
this.deviceCode = deviceCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device code.
|
||||
*
|
||||
* @return the device code
|
||||
*/
|
||||
public String getDeviceCode() {
|
||||
return this.deviceCode;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.StringKeyGenerator;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
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.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} implementation for the Verification {@code URI}
|
||||
* (submission of the user code)} used in the OAuth 2.0 Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2DeviceVerificationAuthenticationToken
|
||||
* @see OAuth2AuthorizationConsent
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationProvider
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationProvider
|
||||
* @see OAuth2DeviceCodeAuthenticationProvider
|
||||
* @see RegisteredClientRepository
|
||||
* @see OAuth2AuthorizationService
|
||||
* @see OAuth2AuthorizationConsentService
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3">Section 3.3 User Interaction</a>
|
||||
*/
|
||||
public final class OAuth2DeviceVerificationAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
|
||||
private static final StringKeyGenerator DEFAULT_STATE_GENERATOR =
|
||||
new Base64StringKeyGenerator(Base64.getUrlEncoder());
|
||||
|
||||
private final Log logger = LogFactory.getLog(getClass());
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
private final OAuth2AuthorizationConsentService authorizationConsentService;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceVerificationAuthenticationProvider} using the provided parameters.
|
||||
*
|
||||
* @param registeredClientRepository the repository of registered clients
|
||||
* @param authorizationService the authorization service
|
||||
* @param authorizationConsentService the authorization consent service
|
||||
*/
|
||||
public OAuth2DeviceVerificationAuthenticationProvider(
|
||||
RegisteredClientRepository registeredClientRepository,
|
||||
OAuth2AuthorizationService authorizationService,
|
||||
OAuth2AuthorizationConsentService authorizationConsentService) {
|
||||
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
|
||||
Assert.notNull(authorizationService, "authorizationService cannot be null");
|
||||
Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null");
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
this.authorizationService = authorizationService;
|
||||
this.authorizationConsentService = authorizationConsentService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication =
|
||||
(OAuth2DeviceVerificationAuthenticationToken) authentication;
|
||||
|
||||
OAuth2Authorization authorization = this.authorizationService.findByToken(
|
||||
deviceVerificationAuthentication.getUserCode(), USER_CODE_TOKEN_TYPE);
|
||||
if (authorization == null) {
|
||||
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
|
||||
}
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved authorization with user code");
|
||||
}
|
||||
|
||||
RegisteredClient registeredClient = this.registeredClientRepository.findById(
|
||||
authorization.getRegisteredClientId());
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Retrieved registered client");
|
||||
}
|
||||
|
||||
Authentication principal = (Authentication) deviceVerificationAuthentication.getPrincipal();
|
||||
if (!isPrincipalAuthenticated(principal)) {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Did not authenticate device authorization request since principal not authenticated");
|
||||
}
|
||||
// Return the authorization request as-is where isAuthenticated() is false
|
||||
return deviceVerificationAuthentication;
|
||||
}
|
||||
|
||||
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName());
|
||||
|
||||
OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById(
|
||||
registeredClient.getId(), principal.getName());
|
||||
|
||||
Set<String> currentAuthorizedScopes = currentAuthorizationConsent != null ?
|
||||
currentAuthorizationConsent.getScopes() : null;
|
||||
|
||||
if (requiresAuthorizationConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) {
|
||||
String state = DEFAULT_STATE_GENERATOR.generateKey();
|
||||
authorization = OAuth2Authorization.from(authorization)
|
||||
.attribute(OAuth2ParameterNames.STATE, state)
|
||||
.build();
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
logger.trace("Generated authorization consent state");
|
||||
}
|
||||
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Saved authorization");
|
||||
}
|
||||
|
||||
return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationRequest.getAuthorizationUri(),
|
||||
registeredClient.getClientId(), principal, deviceVerificationAuthentication.getUserCode(), state,
|
||||
authorizationRequest.getScopes(), currentAuthorizedScopes);
|
||||
}
|
||||
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
||||
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
|
||||
OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
|
||||
.principalName(principal.getName())
|
||||
.authorizedScopes(currentAuthorizedScopes)
|
||||
.token(deviceCode.getToken(), metadata -> metadata
|
||||
.put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true))
|
||||
.token(userCode.getToken(), metadata -> metadata
|
||||
.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
|
||||
.attribute(Principal.class.getName(), principal)
|
||||
.attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE))
|
||||
.build();
|
||||
this.authorizationService.save(updatedAuthorization);
|
||||
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Saved authorization with authorized scopes");
|
||||
// This log is kept separate for consistency with other providers
|
||||
this.logger.trace("Authenticated authorization consent request");
|
||||
}
|
||||
|
||||
return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal,
|
||||
deviceVerificationAuthentication.getUserCode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return OAuth2DeviceVerificationAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
|
||||
private static boolean requiresAuthorizationConsent(RegisteredClient registeredClient,
|
||||
OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent authorizationConsent) {
|
||||
|
||||
if (!registeredClient.getClientSettings().isRequireAuthorizationConsent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authorizationConsent != null &&
|
||||
authorizationConsent.getScopes().containsAll(authorizationRequest.getScopes())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isPrincipalAuthenticated(Authentication principal) {
|
||||
return principal != null &&
|
||||
!AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) &&
|
||||
principal.isAuthenticated();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* An {@link Authentication} implementation for the Verification {@code URI}
|
||||
* (submission of the user code) used in the OAuth 2.0 Device Authorization Grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AbstractAuthenticationToken
|
||||
* @see OAuth2DeviceVerificationAuthenticationProvider
|
||||
*/
|
||||
public class OAuth2DeviceVerificationAuthenticationToken extends AbstractAuthenticationToken {
|
||||
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
|
||||
private final String clientId;
|
||||
private final Authentication principal;
|
||||
private final String userCode;
|
||||
private final Map<String, Object> additionalParameters;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param principal the {@code Principal} (Resource Owner)
|
||||
* @param userCode the user code associated with the device authorization request
|
||||
* @param additionalParameters the additional parameters
|
||||
*/
|
||||
public OAuth2DeviceVerificationAuthenticationToken(Authentication principal, String userCode,
|
||||
@Nullable Map<String, Object> additionalParameters) {
|
||||
super(Collections.emptyList());
|
||||
Assert.notNull(principal, "principal cannot be null");
|
||||
Assert.notNull(userCode, "userCode cannot be null");
|
||||
this.clientId = null;
|
||||
this.principal = principal;
|
||||
this.userCode = userCode;
|
||||
this.additionalParameters = additionalParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters.
|
||||
*
|
||||
* @param clientId the client identifier
|
||||
* @param principal the {@code Principal} (Resource Owner)
|
||||
* @param userCode the user code associated with the device authorization request
|
||||
*/
|
||||
public OAuth2DeviceVerificationAuthenticationToken(String clientId, Authentication principal, String userCode) {
|
||||
super(Collections.emptyList());
|
||||
Assert.hasText(clientId, "clientId cannot be empty");
|
||||
Assert.notNull(principal, "principal cannot be null");
|
||||
Assert.notNull(userCode, "userCode cannot be null");
|
||||
this.clientId = clientId;
|
||||
this.principal = principal;
|
||||
this.userCode = userCode;
|
||||
this.additionalParameters = null;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return this.principal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the client identifier.
|
||||
*
|
||||
* @return the client identifier
|
||||
*/
|
||||
public String getClientId() {
|
||||
return this.clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user code.
|
||||
*
|
||||
* @return the user code
|
||||
*/
|
||||
public String getUserCode() {
|
||||
return this.userCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the additional parameters.
|
||||
*
|
||||
* @return the additional parameters, or an empty {@code Map} if not available
|
||||
*/
|
||||
public Map<String, Object> getAdditionalParameters() {
|
||||
return this.additionalParameters;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -210,6 +210,30 @@ public final class OAuth2AuthorizationServerConfigurer
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the OAuth 2.0 Device Authorization Endpoint.
|
||||
*
|
||||
* @param deviceAuthorizationEndpointCustomizer the {@link Customizer} providing access to the {@link OAuth2DeviceAuthorizationEndpointConfigurer}
|
||||
* @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
|
||||
* @since 1.1
|
||||
*/
|
||||
public OAuth2AuthorizationServerConfigurer deviceAuthorizationEndpoint(Customizer<OAuth2DeviceAuthorizationEndpointConfigurer> deviceAuthorizationEndpointCustomizer) {
|
||||
deviceAuthorizationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceAuthorizationEndpointConfigurer.class));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the OAuth 2.0 Device Verification Endpoint.
|
||||
*
|
||||
* @param deviceVerificationEndpointCustomizer the {@link Customizer} providing access to the {@link OAuth2DeviceVerificationEndpointConfigurer}
|
||||
* @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
|
||||
* @since 1.1
|
||||
*/
|
||||
public OAuth2AuthorizationServerConfigurer deviceVerificationEndpoint(Customizer<OAuth2DeviceVerificationEndpointConfigurer> deviceVerificationEndpointCustomizer) {
|
||||
deviceVerificationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceVerificationEndpointConfigurer.class));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures OpenID Connect 1.0 support (disabled by default).
|
||||
*
|
||||
@@ -326,6 +350,8 @@ public final class OAuth2AuthorizationServerConfigurer
|
||||
configurers.put(OAuth2TokenEndpointConfigurer.class, new OAuth2TokenEndpointConfigurer(this::postProcess));
|
||||
configurers.put(OAuth2TokenIntrospectionEndpointConfigurer.class, new OAuth2TokenIntrospectionEndpointConfigurer(this::postProcess));
|
||||
configurers.put(OAuth2TokenRevocationEndpointConfigurer.class, new OAuth2TokenRevocationEndpointConfigurer(this::postProcess));
|
||||
configurers.put(OAuth2DeviceAuthorizationEndpointConfigurer.class, new OAuth2DeviceAuthorizationEndpointConfigurer(this::postProcess));
|
||||
configurers.put(OAuth2DeviceVerificationEndpointConfigurer.class, new OAuth2DeviceVerificationEndpointConfigurer(this::postProcess));
|
||||
return configurers;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -168,6 +168,9 @@ public final class OAuth2ClientAuthenticationConfigurer extends AbstractOAuth2Co
|
||||
HttpMethod.POST.name()),
|
||||
new AntPathRequestMatcher(
|
||||
authorizationServerSettings.getTokenRevocationEndpoint(),
|
||||
HttpMethod.POST.name()),
|
||||
new AntPathRequestMatcher(
|
||||
authorizationServerSettings.getDeviceAuthorizationEndpoint(),
|
||||
HttpMethod.POST.name()));
|
||||
|
||||
List<AuthenticationProvider> authenticationProviders = createDefaultAuthenticationProviders(httpSecurity);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.config.annotation.web.configurers;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationRequestAuthenticationConverter;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Configurer for the OAuth 2.0 Device Authorization Endpoint.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2AuthorizationServerConfigurer#deviceAuthorizationEndpoint
|
||||
* @see OAuth2DeviceAuthorizationEndpointFilter
|
||||
*/
|
||||
public final class OAuth2DeviceAuthorizationEndpointConfigurer extends AbstractOAuth2Configurer {
|
||||
|
||||
private RequestMatcher requestMatcher;
|
||||
private final List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
|
||||
private Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer = (authenticationConverters) -> {};
|
||||
private final List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
|
||||
private Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer = (authenticationProviders) -> {};
|
||||
private AuthenticationSuccessHandler deviceAuthorizationResponseHandler;
|
||||
private AuthenticationFailureHandler errorResponseHandler;
|
||||
private String verificationUri;
|
||||
|
||||
/**
|
||||
* Restrict for internal use only.
|
||||
*/
|
||||
OAuth2DeviceAuthorizationEndpointConfigurer(ObjectPostProcessor<Object> objectPostProcessor) {
|
||||
super(objectPostProcessor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest}
|
||||
* to an instance of {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating the request.
|
||||
*
|
||||
* @param deviceAuthorizationRequestConverter the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest}
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverter(AuthenticationConverter deviceAuthorizationRequestConverter) {
|
||||
Assert.notNull(deviceAuthorizationRequestConverter, "deviceAuthorizationRequestConverter cannot be null");
|
||||
this.authenticationConverters.add(deviceAuthorizationRequestConverter);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the {@code List} of default
|
||||
* and (optionally) added {@link #deviceAuthorizationRequestConverter(AuthenticationConverter) AuthenticationConverter}'s
|
||||
* allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}.
|
||||
*
|
||||
* @param deviceAuthorizationRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverters(
|
||||
Consumer<List<AuthenticationConverter>> deviceAuthorizationRequestConvertersConsumer) {
|
||||
Assert.notNull(deviceAuthorizationRequestConvertersConsumer, "deviceAuthorizationRequestConvertersConsumer cannot be null");
|
||||
this.authenticationConvertersConsumer = deviceAuthorizationRequestConvertersConsumer;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}.
|
||||
*
|
||||
* @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) {
|
||||
Assert.notNull(authenticationProvider, "authenticationProvider cannot be null");
|
||||
this.authenticationProviders.add(authenticationProvider);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the {@code List} of default
|
||||
* and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s
|
||||
* allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}.
|
||||
*
|
||||
* @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProviders(
|
||||
Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer) {
|
||||
Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null");
|
||||
this.authenticationProvidersConsumer = authenticationProvidersConsumer;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* and returning the Device Authorization Response.
|
||||
*
|
||||
* @param deviceAuthorizationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationResponseHandler(AuthenticationSuccessHandler deviceAuthorizationResponseHandler) {
|
||||
this.deviceAuthorizationResponseHandler = deviceAuthorizationResponseHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* and returning the {@link OAuth2Error Error Response}.
|
||||
*
|
||||
* @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) {
|
||||
this.errorResponseHandler = errorResponseHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the end-user verification {@code URI} on the authorization server.
|
||||
*
|
||||
* @param verificationUri the end-user verification {@code URI} on the authorization server
|
||||
* @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointConfigurer verificationUri(String verificationUri) {
|
||||
this.verificationUri = verificationUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(HttpSecurity builder) {
|
||||
AuthorizationServerSettings authorizationServerSettings =
|
||||
OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder);
|
||||
this.requestMatcher = new AntPathRequestMatcher(
|
||||
authorizationServerSettings.getDeviceAuthorizationEndpoint(), HttpMethod.POST.name());
|
||||
|
||||
List<AuthenticationProvider> authenticationProviders = createDefaultAuthenticationProviders(builder);
|
||||
if (!this.authenticationProviders.isEmpty()) {
|
||||
authenticationProviders.addAll(0, this.authenticationProviders);
|
||||
}
|
||||
this.authenticationProvidersConsumer.accept(authenticationProviders);
|
||||
authenticationProviders.forEach(authenticationProvider ->
|
||||
builder.authenticationProvider(postProcess(authenticationProvider)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(HttpSecurity builder) {
|
||||
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
|
||||
AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder);
|
||||
|
||||
OAuth2DeviceAuthorizationEndpointFilter deviceAuthorizationEndpointFilter =
|
||||
new OAuth2DeviceAuthorizationEndpointFilter(
|
||||
authenticationManager, authorizationServerSettings.getDeviceAuthorizationEndpoint());
|
||||
|
||||
List<AuthenticationConverter> authenticationConverters = createDefaultAuthenticationConverters();
|
||||
if (!this.authenticationConverters.isEmpty()) {
|
||||
authenticationConverters.addAll(0, this.authenticationConverters);
|
||||
}
|
||||
this.authenticationConvertersConsumer.accept(authenticationConverters);
|
||||
deviceAuthorizationEndpointFilter.setAuthenticationConverter(
|
||||
new DelegatingAuthenticationConverter(authenticationConverters));
|
||||
if (this.deviceAuthorizationResponseHandler != null) {
|
||||
deviceAuthorizationEndpointFilter.setAuthenticationSuccessHandler(this.deviceAuthorizationResponseHandler);
|
||||
}
|
||||
if (this.errorResponseHandler != null) {
|
||||
deviceAuthorizationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler);
|
||||
}
|
||||
if (this.verificationUri != null) {
|
||||
deviceAuthorizationEndpointFilter.setVerificationUri(this.verificationUri);
|
||||
}
|
||||
builder.addFilterAfter(postProcess(deviceAuthorizationEndpointFilter), AuthorizationFilter.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
RequestMatcher getRequestMatcher() {
|
||||
return this.requestMatcher;
|
||||
}
|
||||
|
||||
private static List<AuthenticationConverter> createDefaultAuthenticationConverters() {
|
||||
List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
|
||||
authenticationConverters.add(new OAuth2DeviceAuthorizationRequestAuthenticationConverter());
|
||||
|
||||
return authenticationConverters;
|
||||
}
|
||||
|
||||
private List<AuthenticationProvider> createDefaultAuthenticationProviders(HttpSecurity builder) {
|
||||
List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
|
||||
|
||||
OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(builder);
|
||||
|
||||
OAuth2DeviceAuthorizationRequestAuthenticationProvider deviceAuthorizationRequestAuthenticationProvider =
|
||||
new OAuth2DeviceAuthorizationRequestAuthenticationProvider(authorizationService);
|
||||
authenticationProviders.add(deviceAuthorizationRequestAuthenticationProvider);
|
||||
|
||||
return authenticationProviders;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.config.annotation.web.configurers;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationConsentAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceVerificationAuthenticationConverter;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.OrRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Configurer for the OAuth 2.0 Device Verification Endpoint.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see OAuth2AuthorizationServerConfigurer#deviceVerificationEndpoint
|
||||
* @see OAuth2DeviceVerificationEndpointFilter
|
||||
*/
|
||||
public final class OAuth2DeviceVerificationEndpointConfigurer extends AbstractOAuth2Configurer {
|
||||
|
||||
private RequestMatcher requestMatcher;
|
||||
private final List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
|
||||
private Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer = (authenticationConverters) -> {};
|
||||
private final List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
|
||||
private Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer = (authenticationProviders) -> {};
|
||||
private AuthenticationSuccessHandler deviceVerificationResponseHandler;
|
||||
private AuthenticationFailureHandler errorResponseHandler;
|
||||
private String consentPage;
|
||||
|
||||
/**
|
||||
* Restrict for internal use only.
|
||||
*/
|
||||
OAuth2DeviceVerificationEndpointConfigurer(ObjectPostProcessor<Object> objectPostProcessor) {
|
||||
super(objectPostProcessor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Consent) from {@link HttpServletRequest}
|
||||
* to an instance of {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} used for authenticating the request.
|
||||
*
|
||||
* @param deviceVerificationRequestConverter the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest}
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverter(AuthenticationConverter deviceVerificationRequestConverter) {
|
||||
Assert.notNull(deviceVerificationRequestConverter, "deviceVerificationRequestConverter cannot be null");
|
||||
this.authenticationConverters.add(deviceVerificationRequestConverter);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the {@code List} of default
|
||||
* and (optionally) added {@link #deviceVerificationRequestConverter(AuthenticationConverter) AuthenticationConverter}'s
|
||||
* allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}.
|
||||
*
|
||||
* @param deviceVerificationRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverters(
|
||||
Consumer<List<AuthenticationConverter>> deviceVerificationRequestConvertersConsumer) {
|
||||
Assert.notNull(deviceVerificationRequestConvertersConsumer, "deviceVerificationRequestConvertersConsumer cannot be null");
|
||||
this.authenticationConvertersConsumer = deviceVerificationRequestConvertersConsumer;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken}.
|
||||
*
|
||||
* @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken}
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) {
|
||||
Assert.notNull(authenticationProvider, "authenticationProvider cannot be null");
|
||||
this.authenticationProviders.add(authenticationProvider);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@code Consumer} providing access to the {@code List} of default
|
||||
* and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s
|
||||
* allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}.
|
||||
*
|
||||
* @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer authenticationProviders(
|
||||
Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer) {
|
||||
Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null");
|
||||
this.authenticationProvidersConsumer = authenticationProvidersConsumer;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}
|
||||
* and returning the response.
|
||||
*
|
||||
* @param deviceVerificationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken}
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationResponseHandler(AuthenticationSuccessHandler deviceVerificationResponseHandler) {
|
||||
this.deviceVerificationResponseHandler = deviceVerificationResponseHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException}
|
||||
* and returning the {@link OAuth2Error Error Response}.
|
||||
*
|
||||
* @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException}
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) {
|
||||
this.errorResponseHandler = errorResponseHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the URI to redirect Resource Owners to if consent is required during
|
||||
* the {@code device_code} flow. A default consent page will be generated when
|
||||
* this attribute is not specified.
|
||||
*
|
||||
* If a URI is specified, applications are required to process the specified URI 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} - a space-delimited list of scopes present in the authorization request</li>
|
||||
* <li>{@code state} - a CSRF protection token</li>
|
||||
* <li>@code code} - the user code</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 AuthorizationServerSettings#getDeviceVerificationEndpoint()}</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 Owner}
|
||||
* consented to as an HTTP parameter</li>
|
||||
* <li>It must include the user {@code code} as an HTTP parameter</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param consentPage the URI of the custom consent page to redirect to if consent is required (e.g. "/oauth2/consent")
|
||||
* @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration
|
||||
*/
|
||||
public OAuth2DeviceVerificationEndpointConfigurer consentPage(String consentPage) {
|
||||
this.consentPage = consentPage;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(HttpSecurity builder) {
|
||||
AuthorizationServerSettings authorizationServerSettings =
|
||||
OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder);
|
||||
this.requestMatcher = new OrRequestMatcher(
|
||||
new AntPathRequestMatcher(
|
||||
authorizationServerSettings.getDeviceVerificationEndpoint(), HttpMethod.GET.name()),
|
||||
new AntPathRequestMatcher(
|
||||
authorizationServerSettings.getDeviceVerificationEndpoint(), HttpMethod.POST.name()));
|
||||
|
||||
List<AuthenticationProvider> authenticationProviders = createDefaultAuthenticationProviders(builder);
|
||||
if (!this.authenticationProviders.isEmpty()) {
|
||||
authenticationProviders.addAll(0, this.authenticationProviders);
|
||||
}
|
||||
this.authenticationProvidersConsumer.accept(authenticationProviders);
|
||||
authenticationProviders.forEach(authenticationProvider ->
|
||||
builder.authenticationProvider(postProcess(authenticationProvider)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(HttpSecurity builder) {
|
||||
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
|
||||
|
||||
AuthorizationServerSettings authorizationServerSettings =
|
||||
OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder);
|
||||
|
||||
OAuth2DeviceVerificationEndpointFilter deviceVerificationEndpointFilter =
|
||||
new OAuth2DeviceVerificationEndpointFilter(
|
||||
authenticationManager, authorizationServerSettings.getDeviceVerificationEndpoint());
|
||||
List<AuthenticationConverter> authenticationConverters = createDefaultAuthenticationConverters();
|
||||
if (!this.authenticationConverters.isEmpty()) {
|
||||
authenticationConverters.addAll(0, this.authenticationConverters);
|
||||
}
|
||||
this.authenticationConvertersConsumer.accept(authenticationConverters);
|
||||
deviceVerificationEndpointFilter.setAuthenticationConverter(
|
||||
new DelegatingAuthenticationConverter(authenticationConverters));
|
||||
if (this.deviceVerificationResponseHandler != null) {
|
||||
deviceVerificationEndpointFilter.setAuthenticationSuccessHandler(this.deviceVerificationResponseHandler);
|
||||
}
|
||||
if (this.errorResponseHandler != null) {
|
||||
deviceVerificationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler);
|
||||
}
|
||||
if (StringUtils.hasText(this.consentPage)) {
|
||||
deviceVerificationEndpointFilter.setConsentPage(this.consentPage);
|
||||
}
|
||||
builder.addFilterAfter(postProcess(deviceVerificationEndpointFilter), AuthorizationFilter.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
RequestMatcher getRequestMatcher() {
|
||||
return this.requestMatcher;
|
||||
}
|
||||
|
||||
private static List<AuthenticationConverter> createDefaultAuthenticationConverters() {
|
||||
List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
|
||||
authenticationConverters.add(new OAuth2DeviceVerificationAuthenticationConverter());
|
||||
authenticationConverters.add(new OAuth2DeviceAuthorizationConsentAuthenticationConverter());
|
||||
|
||||
return authenticationConverters;
|
||||
}
|
||||
|
||||
private static List<AuthenticationProvider> createDefaultAuthenticationProviders(HttpSecurity builder) {
|
||||
RegisteredClientRepository registeredClientRepository =
|
||||
OAuth2ConfigurerUtils.getRegisteredClientRepository(builder);
|
||||
OAuth2AuthorizationService authorizationService =
|
||||
OAuth2ConfigurerUtils.getAuthorizationService(builder);
|
||||
OAuth2AuthorizationConsentService authorizationConsentService =
|
||||
OAuth2ConfigurerUtils.getAuthorizationConsentService(builder);
|
||||
|
||||
List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
|
||||
|
||||
// @formatter:off
|
||||
OAuth2DeviceVerificationAuthenticationProvider deviceVerificationAuthenticationProvider =
|
||||
new OAuth2DeviceVerificationAuthenticationProvider(
|
||||
registeredClientRepository, authorizationService, authorizationConsentService);
|
||||
// @formatter:on
|
||||
authenticationProviders.add(deviceVerificationAuthenticationProvider);
|
||||
|
||||
// @formatter:off
|
||||
OAuth2DeviceAuthorizationConsentAuthenticationProvider deviceAuthorizationConsentAuthenticationProvider =
|
||||
new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
|
||||
registeredClientRepository, authorizationService, authorizationConsentService);
|
||||
// @formatter:on
|
||||
authenticationProviders.add(deviceAuthorizationConsentAuthenticationProvider);
|
||||
|
||||
return authenticationProviders;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -36,6 +36,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
|
||||
@@ -43,6 +44,7 @@ import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenE
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
@@ -208,6 +210,7 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure
|
||||
authenticationConverters.add(new OAuth2AuthorizationCodeAuthenticationConverter());
|
||||
authenticationConverters.add(new OAuth2RefreshTokenAuthenticationConverter());
|
||||
authenticationConverters.add(new OAuth2ClientCredentialsAuthenticationConverter());
|
||||
authenticationConverters.add(new OAuth2DeviceCodeAuthenticationConverter());
|
||||
|
||||
return authenticationConverters;
|
||||
}
|
||||
@@ -232,6 +235,10 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure
|
||||
new OAuth2ClientCredentialsAuthenticationProvider(authorizationService, tokenGenerator);
|
||||
authenticationProviders.add(clientCredentialsAuthenticationProvider);
|
||||
|
||||
OAuth2DeviceCodeAuthenticationProvider deviceCodeAuthenticationProvider =
|
||||
new OAuth2DeviceCodeAuthenticationProvider(authorizationService, tokenGenerator);
|
||||
authenticationProviders.add(deviceCodeAuthenticationProvider);
|
||||
|
||||
return authenticationProviders;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,24 @@ public final class AuthorizationServerSettings extends AbstractSettings {
|
||||
return getSetting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OAuth 2.0 Device Authorization endpoint. The default is {@code /oauth2/device_authorization}.
|
||||
*
|
||||
* @return the Authorization endpoint
|
||||
*/
|
||||
public String getDeviceAuthorizationEndpoint() {
|
||||
return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_AUTHORIZATION_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OAuth 2.0 Device VERIFICATION endpoint. The default is {@code /oauth2/device_verification}.
|
||||
*
|
||||
* @return the Authorization endpoint
|
||||
*/
|
||||
public String getDeviceVerificationEndpoint() {
|
||||
return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}.
|
||||
*
|
||||
@@ -124,6 +142,8 @@ public final class AuthorizationServerSettings extends AbstractSettings {
|
||||
public static Builder builder() {
|
||||
return new Builder()
|
||||
.authorizationEndpoint("/oauth2/authorize")
|
||||
.deviceAuthorizationEndpoint("/oauth2/device_authorization")
|
||||
.deviceVerificationEndpoint("/oauth2/device_verification")
|
||||
.tokenEndpoint("/oauth2/token")
|
||||
.jwkSetEndpoint("/oauth2/jwks")
|
||||
.tokenRevocationEndpoint("/oauth2/revoke")
|
||||
@@ -173,6 +193,26 @@ public final class AuthorizationServerSettings extends AbstractSettings {
|
||||
return setting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT, authorizationEndpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the OAuth 2.0 Device Authorization endpoint.
|
||||
*
|
||||
* @param deviceAuthorizationEndpoint the Device Authorization endpoint
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) {
|
||||
return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_AUTHORIZATION_ENDPOINT, deviceAuthorizationEndpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the OAuth 2.0 Device Verification endpoint.
|
||||
*
|
||||
* @param deviceVerificationEndpoint the Device Verification endpoint
|
||||
* @return the {@link Builder} for further configuration
|
||||
*/
|
||||
public Builder deviceVerificationEndpoint(String deviceVerificationEndpoint) {
|
||||
return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT, deviceVerificationEndpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the OAuth 2.0 Token endpoint.
|
||||
*
|
||||
|
||||
@@ -86,6 +86,16 @@ public final class ConfigurationSettingNames {
|
||||
*/
|
||||
public static final String AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("authorization-endpoint");
|
||||
|
||||
/**
|
||||
* Set the OAuth 2.0 Device Authorization endpoint.
|
||||
*/
|
||||
public static final String DEVICE_AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("device-authorization-endpoint");
|
||||
|
||||
/**
|
||||
* Set the OAuth 2.0 Device Verification endpoint.
|
||||
*/
|
||||
public static final String DEVICE_VERIFICATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("device-verification-endpoint");
|
||||
|
||||
/**
|
||||
* Set the OAuth 2.0 Token endpoint.
|
||||
*/
|
||||
@@ -150,6 +160,12 @@ public final class ConfigurationSettingNames {
|
||||
*/
|
||||
public static final String ACCESS_TOKEN_FORMAT = TOKEN_SETTINGS_NAMESPACE.concat("access-token-format");
|
||||
|
||||
/**
|
||||
* Set the time-to-live for a device code.
|
||||
* @since 1.1
|
||||
*/
|
||||
public static final String DEVICE_CODE_TIME_TO_LIVE = TOKEN_SETTINGS_NAMESPACE.concat("device-code-time-to-live");
|
||||
|
||||
/**
|
||||
* Set to {@code true} if refresh tokens are reused when returning the access token response,
|
||||
* or {@code false} if a new refresh token is issued.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -66,6 +66,16 @@ public final class TokenSettings extends AbstractSettings {
|
||||
return getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time-to-live for a device code. The default is 30 minutes.
|
||||
*
|
||||
* @return the time-to-live for an authorization code
|
||||
* @since 1.1
|
||||
*/
|
||||
public Duration getDeviceCodeTimeToLive() {
|
||||
return getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if refresh tokens are reused when returning the access token response,
|
||||
* or {@code false} if a new refresh token is issued. The default is {@code true}.
|
||||
@@ -103,6 +113,7 @@ public final class TokenSettings extends AbstractSettings {
|
||||
.authorizationCodeTimeToLive(Duration.ofMinutes(5))
|
||||
.accessTokenTimeToLive(Duration.ofMinutes(5))
|
||||
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
|
||||
.deviceCodeTimeToLive(Duration.ofMinutes(30))
|
||||
.reuseRefreshTokens(true)
|
||||
.refreshTokenTimeToLive(Duration.ofMinutes(60))
|
||||
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256);
|
||||
@@ -166,6 +177,19 @@ public final class TokenSettings extends AbstractSettings {
|
||||
return setting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, accessTokenFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the time-to-live for a device code. Must be greater than {@code Duration.ZERO}.
|
||||
*
|
||||
* @param deviceCodeTimeToLive the time-to-live for a device code
|
||||
* @return the {@link Builder} for further configuration
|
||||
* @since 1.1
|
||||
*/
|
||||
public Builder deviceCodeTimeToLive(Duration deviceCodeTimeToLive) {
|
||||
Assert.notNull(deviceCodeTimeToLive, "deviceCodeTimeToLive cannot be null");
|
||||
Assert.isTrue(deviceCodeTimeToLive.getSeconds() > 0, "deviceCodeTimeToLive must be greater than Duration.ZERO");
|
||||
return setting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, deviceCodeTimeToLive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set to {@code true} if refresh tokens are reused when returning the access token response,
|
||||
* or {@code false} if a new refresh token is issued.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
|
||||
/**
|
||||
* For internal use only.
|
||||
*/
|
||||
class DefaultConsentPage {
|
||||
private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8);
|
||||
|
||||
private DefaultConsentPage() {
|
||||
}
|
||||
|
||||
static void displayConsent(HttpServletRequest request, HttpServletResponse response, String clientId,
|
||||
Authentication principal, Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
||||
Map<String, String> additionalParameters) throws IOException {
|
||||
|
||||
String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state, additionalParameters);
|
||||
response.setContentType(TEXT_HTML_UTF8.toString());
|
||||
response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length);
|
||||
response.getWriter().write(consentPage);
|
||||
}
|
||||
|
||||
private static String generateConsentPage(HttpServletRequest request,
|
||||
String clientId, Authentication principal, Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
||||
Map<String, String> additionalParameters) {
|
||||
Set<String> scopesToAuthorize = new HashSet<>();
|
||||
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
||||
for (String scope : requestedScopes) {
|
||||
if (authorizedScopes.contains(scope)) {
|
||||
scopesPreviouslyAuthorized.add(scope);
|
||||
} else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent
|
||||
scopesToAuthorize.add(scope);
|
||||
}
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1
|
||||
// The server SHOULD display
|
||||
// the "user_code" to the user and ask them to verify that it matches
|
||||
// the "user_code" being displayed on the device to confirm they are
|
||||
// authorizing the correct device.
|
||||
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
builder.append("<!DOCTYPE html>");
|
||||
builder.append("<html lang=\"en\">");
|
||||
builder.append("<head>");
|
||||
builder.append(" <meta charset=\"utf-8\">");
|
||||
builder.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">");
|
||||
builder.append(" <link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css\" integrity=\"sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z\" crossorigin=\"anonymous\">");
|
||||
builder.append(" <title>Consent required</title>");
|
||||
builder.append(" <script>");
|
||||
builder.append(" function cancelConsent() {");
|
||||
builder.append(" document.consent_form.reset();");
|
||||
builder.append(" document.consent_form.submit();");
|
||||
builder.append(" }");
|
||||
builder.append(" </script>");
|
||||
builder.append("</head>");
|
||||
builder.append("<body>");
|
||||
builder.append("<div class=\"container\">");
|
||||
builder.append(" <div class=\"py-5\">");
|
||||
builder.append(" <h1 class=\"text-center\">Consent required</h1>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
if (userCode != null) {
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
builder.append(" <div class=\"row pb-3\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p>The following permissions are requested by the above app.<br/>Please review these and consent if you approve.</p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
|
||||
if (userCode != null) {
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
|
||||
}
|
||||
|
||||
for (String scope : scopesToAuthorize) {
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
|
||||
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||
for (String scope : scopesPreviouslyAuthorized) {
|
||||
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\" id=\"submit-consent\">Submit Consent</button>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"form-group\">");
|
||||
builder.append(" <button class=\"btn btn-link regular\" type=\"button\" onclick=\"cancelConsent();\" id=\"cancel-consent\">Cancel</button>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </form>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row pt-4\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <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>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append("</div>");
|
||||
builder.append("</body>");
|
||||
builder.append("</html>");
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ package org.springframework.security.oauth2.server.authorization.web;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
@@ -29,7 +29,6 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.authentication.AuthenticationDetailsSource;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
@@ -288,7 +287,7 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Displaying generated consent screen");
|
||||
}
|
||||
DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state);
|
||||
DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state, Collections.emptyMap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,107 +366,4 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
|
||||
this.redirectStrategy.sendRedirect(request, response, redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* For internal use only.
|
||||
*/
|
||||
private static class DefaultConsentPage {
|
||||
private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8);
|
||||
|
||||
private static void displayConsent(HttpServletRequest request, HttpServletResponse response,
|
||||
String clientId, Authentication principal, Set<String> requestedScopes, Set<String> authorizedScopes, String state)
|
||||
throws IOException {
|
||||
|
||||
String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state);
|
||||
response.setContentType(TEXT_HTML_UTF8.toString());
|
||||
response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length);
|
||||
response.getWriter().write(consentPage);
|
||||
}
|
||||
|
||||
private static String generateConsentPage(HttpServletRequest request,
|
||||
String clientId, Authentication principal, Set<String> requestedScopes, Set<String> authorizedScopes, String state) {
|
||||
Set<String> scopesToAuthorize = new HashSet<>();
|
||||
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
||||
for (String scope : requestedScopes) {
|
||||
if (authorizedScopes.contains(scope)) {
|
||||
scopesPreviouslyAuthorized.add(scope);
|
||||
} else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent
|
||||
scopesToAuthorize.add(scope);
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
builder.append("<!DOCTYPE html>");
|
||||
builder.append("<html lang=\"en\">");
|
||||
builder.append("<head>");
|
||||
builder.append(" <meta charset=\"utf-8\">");
|
||||
builder.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">");
|
||||
builder.append(" <link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css\" integrity=\"sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z\" crossorigin=\"anonymous\">");
|
||||
builder.append(" <title>Consent required</title>");
|
||||
builder.append(" <script>");
|
||||
builder.append(" function cancelConsent() {");
|
||||
builder.append(" document.consent_form.reset();");
|
||||
builder.append(" document.consent_form.submit();");
|
||||
builder.append(" }");
|
||||
builder.append(" </script>");
|
||||
builder.append("</head>");
|
||||
builder.append("<body>");
|
||||
builder.append("<div class=\"container\">");
|
||||
builder.append(" <div class=\"py-5\">");
|
||||
builder.append(" <h1 class=\"text-center\">Consent required</h1>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row pb-3\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p>The following permissions are requested by the above app.<br/>Please review these and consent if you approve.</p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
|
||||
|
||||
for (String scope : scopesToAuthorize) {
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
|
||||
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||
for (String scope : scopesPreviouslyAuthorized) {
|
||||
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\" id=\"submit-consent\">Submit Consent</button>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"form-group\">");
|
||||
builder.append(" <button class=\"btn btn-link regular\" type=\"button\" onclick=\"cancelConsent();\" id=\"cancel-consent\">Cancel</button>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </form>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row pt-4\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <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>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
builder.append("</div>");
|
||||
builder.append("</body>");
|
||||
builder.append("</html>");
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.security.authentication.AuthenticationDetailsSource;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter;
|
||||
import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationRequestAuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* A {@code Filter} for the OAuth 2.0 Device Authorization Grant,
|
||||
* which handles the processing of the OAuth 2.0 Device Authorization Request.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AuthenticationManager
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationConverter
|
||||
* @see OAuth2DeviceAuthorizationRequestAuthenticationProvider
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.1">Section 3.1 Device Authorization Request</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.2">Section 3.2 Device Authorization Response</a>
|
||||
*/
|
||||
public final class OAuth2DeviceAuthorizationEndpointFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorize";
|
||||
|
||||
private static final String DEFAULT_DEVICE_VERIFICATION_URI = "/oauth2/device_verification";
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final RequestMatcher deviceAuthorizationEndpointMatcher;
|
||||
private final HttpMessageConverter<OAuth2DeviceAuthorizationResponse> deviceAuthorizationHttpResponseConverter =
|
||||
new OAuth2DeviceAuthorizationResponseHttpMessageConverter();
|
||||
private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter =
|
||||
new OAuth2ErrorHttpMessageConverter();
|
||||
private AuthenticationConverter authenticationConverter;
|
||||
private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource =
|
||||
new WebAuthenticationDetailsSource();
|
||||
private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendDeviceAuthorizationResponse;
|
||||
private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse;
|
||||
private String verificationUri = DEFAULT_DEVICE_VERIFICATION_URI;
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters.
|
||||
*
|
||||
* @param authenticationManager the authentication manager
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager) {
|
||||
this(authenticationManager, DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters.
|
||||
*
|
||||
* @param authenticationManager the authentication manager
|
||||
* @param deviceAuthorizationEndpointUri the endpoint {@code URI} for device authorization requests
|
||||
*/
|
||||
public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager, String deviceAuthorizationEndpointUri) {
|
||||
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
|
||||
Assert.hasText(deviceAuthorizationEndpointUri, "deviceAuthorizationEndpointUri cannot be empty");
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.deviceAuthorizationEndpointMatcher = new AntPathRequestMatcher(deviceAuthorizationEndpointUri,
|
||||
HttpMethod.POST.name());
|
||||
this.authenticationConverter = new OAuth2DeviceAuthorizationRequestAuthenticationConverter();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!this.deviceAuthorizationEndpointMatcher.matches(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationToken =
|
||||
(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationConverter.convert(request);
|
||||
deviceAuthorizationRequestAuthenticationToken.setDetails(
|
||||
this.authenticationDetailsSource.buildDetails(request));
|
||||
|
||||
OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationTokenResult =
|
||||
(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationManager.authenticate(
|
||||
deviceAuthorizationRequestAuthenticationToken);
|
||||
|
||||
this.authenticationSuccessHandler.onAuthenticationSuccess(request, response,
|
||||
deviceAuthorizationRequestAuthenticationTokenResult);
|
||||
} catch (OAuth2AuthenticationException ex) {
|
||||
SecurityContextHolder.clearContext();
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace(LogMessage.format("Device authorization request failed: %s", ex.getError()), ex);
|
||||
}
|
||||
this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest}
|
||||
* to an instance of {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating the request.
|
||||
*
|
||||
* @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract a DeviceAuthorization Request from {@link HttpServletRequest}
|
||||
*/
|
||||
public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) {
|
||||
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
|
||||
this.authenticationConverter = authenticationConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}.
|
||||
*
|
||||
* @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}
|
||||
*/
|
||||
public void setAuthenticationDetailsSource(AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
|
||||
Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null");
|
||||
this.authenticationDetailsSource = authenticationDetailsSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* and returning the Device Authorization Response.
|
||||
*
|
||||
* @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
*/
|
||||
public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) {
|
||||
Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
|
||||
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}
|
||||
* and returning the {@link OAuth2Error Error Response}.
|
||||
*
|
||||
* @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationException}
|
||||
*/
|
||||
public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
|
||||
Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
|
||||
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the end-user verification {@code URI} on the authorization server.
|
||||
*
|
||||
* @param verificationUri the end-user verification {@code URI} on the authorization server
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.2">Section 3.2 Device Authorization Response</a>
|
||||
*/
|
||||
public void setVerificationUri(String verificationUri) {
|
||||
Assert.hasText(verificationUri, "verificationUri cannot be empty");
|
||||
this.verificationUri = verificationUri;
|
||||
}
|
||||
|
||||
private void sendDeviceAuthorizationResponse(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException {
|
||||
|
||||
OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationToken =
|
||||
(OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication;
|
||||
|
||||
OAuth2DeviceCode deviceCode = deviceAuthorizationRequestAuthenticationToken.getDeviceCode();
|
||||
OAuth2UserCode userCode = deviceAuthorizationRequestAuthenticationToken.getUserCode();
|
||||
|
||||
// Generate the fully-qualified verification URI
|
||||
String issuerUri = AuthorizationServerContextHolder.getContext().getIssuer();
|
||||
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(issuerUri)
|
||||
.path(this.verificationUri);
|
||||
String verificationUri = uriComponentsBuilder.build().toUriString();
|
||||
// @formatter:off
|
||||
String verificationUriComplete = uriComponentsBuilder
|
||||
.queryParam(OAuth2ParameterNames.USER_CODE, userCode.getTokenValue())
|
||||
.build().toUriString();
|
||||
// @formatter:on
|
||||
|
||||
// @formatter:off
|
||||
OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse =
|
||||
OAuth2DeviceAuthorizationResponse.with(deviceCode, userCode)
|
||||
.verificationUri(verificationUri)
|
||||
.verificationUriComplete(verificationUriComplete)
|
||||
.build();
|
||||
// @formatter:on
|
||||
|
||||
ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
|
||||
this.deviceAuthorizationHttpResponseConverter.write(deviceAuthorizationResponse, null, httpResponse);
|
||||
}
|
||||
|
||||
private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authenticationException) throws IOException {
|
||||
|
||||
OAuth2Error error = ((OAuth2AuthenticationException) authenticationException).getError();
|
||||
ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
|
||||
httpResponse.setStatusCode(HttpStatus.BAD_REQUEST);
|
||||
this.errorHttpResponseConverter.write(error, null, httpResponse);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.core.log.LogMessage;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.authentication.AuthenticationDetailsSource;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationConsentAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceVerificationAuthenticationConverter;
|
||||
import org.springframework.security.web.DefaultRedirectStrategy;
|
||||
import org.springframework.security.web.RedirectStrategy;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.security.web.util.RedirectUrlBuilder;
|
||||
import org.springframework.security.web.util.UrlUtils;
|
||||
import org.springframework.security.web.util.matcher.AndRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.OrRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* A {@code Filter} for the OAuth 2.0 Device Authorization Grant, which handles
|
||||
* the processing of the Verification {@code URI} (submission of the user code)
|
||||
* and OAuth 2.0 Authorization Consent.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AuthenticationManager
|
||||
* @see OAuth2DeviceVerificationAuthenticationConverter
|
||||
* @see OAuth2DeviceVerificationAuthenticationProvider
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationConverter
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationProvider
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
|
||||
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3">Section 3.3 User Interaction</a>
|
||||
*/
|
||||
public final class OAuth2DeviceVerificationEndpointFilter extends OncePerRequestFilter {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final RequestMatcher deviceVerificationEndpointMatcher;
|
||||
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
|
||||
private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource =
|
||||
new WebAuthenticationDetailsSource();
|
||||
private AuthenticationConverter authenticationConverter;
|
||||
private AuthenticationSuccessHandler authenticationSuccessHandler =
|
||||
new SimpleUrlAuthenticationSuccessHandler("/?success");
|
||||
private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse;
|
||||
private String consentPage;
|
||||
|
||||
public OAuth2DeviceVerificationEndpointFilter(AuthenticationManager authenticationManager, String deviceVerificationEndpointUri) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.deviceVerificationEndpointMatcher = createDefaultRequestMatcher(deviceVerificationEndpointUri);
|
||||
this.authenticationConverter = new DelegatingAuthenticationConverter(
|
||||
Arrays.asList(
|
||||
new OAuth2DeviceVerificationAuthenticationConverter(),
|
||||
new OAuth2DeviceAuthorizationConsentAuthenticationConverter()));
|
||||
}
|
||||
|
||||
private RequestMatcher createDefaultRequestMatcher(String deviceVerificationEndpointUri) {
|
||||
RequestMatcher verificationRequestGetMatcher = new AntPathRequestMatcher(
|
||||
deviceVerificationEndpointUri, HttpMethod.GET.name());
|
||||
RequestMatcher verificationRequestPostMatcher = new AntPathRequestMatcher(
|
||||
deviceVerificationEndpointUri, HttpMethod.POST.name());
|
||||
RequestMatcher userCodeParameterMatcher = request ->
|
||||
request.getParameter(OAuth2ParameterNames.USER_CODE) != null;
|
||||
|
||||
return new AndRequestMatcher(
|
||||
new OrRequestMatcher(verificationRequestGetMatcher, verificationRequestPostMatcher),
|
||||
userCodeParameterMatcher);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!this.deviceVerificationEndpointMatcher.matches(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Authentication authentication = this.authenticationConverter.convert(request);
|
||||
if (authentication instanceof AbstractAuthenticationToken) {
|
||||
((AbstractAuthenticationToken) authentication)
|
||||
.setDetails(this.authenticationDetailsSource.buildDetails(request));
|
||||
}
|
||||
|
||||
Authentication authenticationResult = this.authenticationManager.authenticate(authentication);
|
||||
if (!authenticationResult.isAuthenticated()) {
|
||||
// If the Principal (Resource Owner) is not authenticated then
|
||||
// pass through the chain with the expectation that the authentication process
|
||||
// will commence via AuthenticationEntryPoint
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authenticationResult instanceof OAuth2DeviceAuthorizationConsentAuthenticationToken) {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Device authorization consent is required");
|
||||
}
|
||||
sendAuthorizationConsent(request, response, authenticationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, authenticationResult);
|
||||
} catch (OAuth2AuthenticationException ex) {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace(LogMessage.format("Device verification request failed: %s", ex.getError()), ex);
|
||||
}
|
||||
this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}.
|
||||
*
|
||||
* @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}
|
||||
*/
|
||||
public void setAuthenticationDetailsSource(AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
|
||||
Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null");
|
||||
this.authenticationDetailsSource = authenticationDetailsSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationConverter} used when attempting to extract an Authorization Request (or Consent) from {@link HttpServletRequest}
|
||||
* to an instance of {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}
|
||||
* used for authenticating the request.
|
||||
*
|
||||
* @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract an Authorization Request (or Consent) from {@link HttpServletRequest}
|
||||
*/
|
||||
public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) {
|
||||
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
|
||||
this.authenticationConverter = authenticationConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken}
|
||||
* and returning the response.
|
||||
*
|
||||
* @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken}
|
||||
*/
|
||||
public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) {
|
||||
Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
|
||||
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken}
|
||||
* and returning the {@link OAuth2Error Error Response}.
|
||||
*
|
||||
* @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken}
|
||||
*/
|
||||
public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
|
||||
Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
|
||||
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the URI to redirect Resource Owners to if consent is required. A default consent
|
||||
* page will be generated when this attribute is not specified.
|
||||
*
|
||||
* @param consentPage the URI of the custom consent page to redirect to if consent is required (e.g. "/oauth2/consent")
|
||||
*/
|
||||
public void setConsentPage(String consentPage) {
|
||||
this.consentPage = consentPage;
|
||||
}
|
||||
|
||||
private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException {
|
||||
|
||||
OAuth2DeviceAuthorizationConsentAuthenticationToken authorizationConsentAuthentication =
|
||||
(OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication;
|
||||
|
||||
String clientId = authorizationConsentAuthentication.getClientId();
|
||||
Authentication principal = (Authentication) authorizationConsentAuthentication.getPrincipal();
|
||||
Set<String> requestedScopes = authorizationConsentAuthentication.getRequestedScopes();
|
||||
Set<String> authorizedScopes = authorizationConsentAuthentication.getScopes();
|
||||
String state = authorizationConsentAuthentication.getState();
|
||||
String userCode = authorizationConsentAuthentication.getUserCode();
|
||||
|
||||
if (hasConsentUri()) {
|
||||
String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request))
|
||||
.queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes))
|
||||
.queryParam(OAuth2ParameterNames.CLIENT_ID, clientId)
|
||||
.queryParam(OAuth2ParameterNames.STATE, state)
|
||||
.queryParam(OAuth2ParameterNames.USER_CODE, userCode)
|
||||
.toUriString();
|
||||
this.redirectStrategy.sendRedirect(request, response, redirectUri);
|
||||
} else {
|
||||
if (this.logger.isTraceEnabled()) {
|
||||
this.logger.trace("Displaying generated consent screen");
|
||||
}
|
||||
Map<String, String> additionalParameters = new HashMap<>();
|
||||
additionalParameters.put(OAuth2ParameterNames.USER_CODE, userCode);
|
||||
DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state, additionalParameters);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasConsentUri() {
|
||||
return StringUtils.hasText(this.consentPage);
|
||||
}
|
||||
|
||||
private String resolveConsentUri(HttpServletRequest request) {
|
||||
if (UrlUtils.isAbsoluteUrl(this.consentPage)) {
|
||||
return this.consentPage;
|
||||
}
|
||||
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
|
||||
urlBuilder.setScheme(request.getScheme());
|
||||
urlBuilder.setServerName(request.getServerName());
|
||||
urlBuilder.setPort(request.getServerPort());
|
||||
urlBuilder.setContextPath(request.getContextPath());
|
||||
urlBuilder.setPathInfo(this.consentPage);
|
||||
return urlBuilder.getUrl();
|
||||
}
|
||||
|
||||
private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authenticationException) throws IOException {
|
||||
|
||||
OAuth2Error error = ((OAuth2AuthenticationException) authenticationException).getError();
|
||||
response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -53,6 +53,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
@@ -136,7 +137,8 @@ public final class OAuth2TokenEndpointFilter extends OncePerRequestFilter {
|
||||
Arrays.asList(
|
||||
new OAuth2AuthorizationCodeAuthenticationConverter(),
|
||||
new OAuth2RefreshTokenAuthenticationConverter(),
|
||||
new OAuth2ClientCredentialsAuthenticationConverter()));
|
||||
new OAuth2ClientCredentialsAuthenticationConverter(),
|
||||
new OAuth2DeviceCodeAuthenticationConverter()));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web.authentication;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Attempts to extract an Authorization Consent from {@link HttpServletRequest}
|
||||
* for the OAuth 2.0 Device Authorization Grant and then converts it to an
|
||||
* {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} used for
|
||||
* authenticating the request.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AuthenticationConverter
|
||||
* @see OAuth2DeviceAuthorizationConsentAuthenticationToken
|
||||
* @see OAuth2DeviceVerificationEndpointFilter
|
||||
*/
|
||||
public final class OAuth2DeviceAuthorizationConsentAuthenticationConverter implements AuthenticationConverter {
|
||||
|
||||
private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
|
||||
private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3";
|
||||
private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken(
|
||||
"anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
|
||||
@Override
|
||||
public Authentication convert(HttpServletRequest request) {
|
||||
if (!"POST".equals(request.getMethod())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||||
|
||||
String authorizationUri = request.getRequestURL().toString();
|
||||
|
||||
// user_code (REQUIRED)
|
||||
String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE);
|
||||
if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(
|
||||
OAuth2ErrorCodes.INVALID_REQUEST,
|
||||
OAuth2ParameterNames.USER_CODE,
|
||||
DEVICE_ERROR_URI);
|
||||
}
|
||||
|
||||
// client_id (REQUIRED)
|
||||
String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID);
|
||||
if (!StringUtils.hasText(clientId) || parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, DEFAULT_ERROR_URI);
|
||||
}
|
||||
|
||||
Authentication principal = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (principal == null) {
|
||||
principal = ANONYMOUS_AUTHENTICATION;
|
||||
}
|
||||
|
||||
// state (REQUIRED)
|
||||
String state = parameters.getFirst(OAuth2ParameterNames.STATE);
|
||||
if (!StringUtils.hasText(state) || parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(
|
||||
OAuth2ErrorCodes.INVALID_REQUEST,
|
||||
OAuth2ParameterNames.STATE,
|
||||
DEFAULT_ERROR_URI);
|
||||
}
|
||||
|
||||
// scope (OPTIONAL)
|
||||
Set<String> scopes = null;
|
||||
if (parameters.containsKey(OAuth2ParameterNames.SCOPE)) {
|
||||
scopes = new HashSet<>(parameters.get(OAuth2ParameterNames.SCOPE));
|
||||
}
|
||||
|
||||
Map<String, Object> additionalParameters = new HashMap<>();
|
||||
parameters.forEach((key, value) -> {
|
||||
if (!key.equals(OAuth2ParameterNames.CLIENT_ID) &&
|
||||
!key.equals(OAuth2ParameterNames.STATE) &&
|
||||
!key.equals(OAuth2ParameterNames.SCOPE) &&
|
||||
!key.equals(OAuth2ParameterNames.USER_CODE)) {
|
||||
additionalParameters.put(key, value.get(0));
|
||||
}
|
||||
});
|
||||
|
||||
return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationUri, clientId, principal,
|
||||
OAuth2EndpointUtils.normalizeUserCode(userCode), state, scopes, additionalParameters);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web.authentication;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Attempts to extract a Device Authorization Request from {@link HttpServletRequest} for the
|
||||
* OAuth 2.0 Device Authorization Grant and then converts it to an
|
||||
* {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating
|
||||
* the request.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
public final class OAuth2DeviceAuthorizationRequestAuthenticationConverter implements AuthenticationConverter {
|
||||
|
||||
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.1";
|
||||
|
||||
@Override
|
||||
public Authentication convert(HttpServletRequest request) {
|
||||
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||||
|
||||
String authorizationUri = request.getRequestURL().toString();
|
||||
|
||||
// scope (OPTIONAL)
|
||||
String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
|
||||
if (StringUtils.hasText(scope) &&
|
||||
parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(
|
||||
OAuth2ErrorCodes.INVALID_REQUEST,
|
||||
OAuth2ParameterNames.SCOPE,
|
||||
ERROR_URI);
|
||||
}
|
||||
Set<String> requestedScopes = null;
|
||||
if (StringUtils.hasText(scope)) {
|
||||
requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
|
||||
}
|
||||
|
||||
Map<String, Object> additionalParameters = new HashMap<>();
|
||||
parameters.forEach((key, value) -> {
|
||||
if (!key.equals(OAuth2ParameterNames.CLIENT_ID) &&
|
||||
!key.equals(OAuth2ParameterNames.SCOPE)) {
|
||||
additionalParameters.put(key, value.get(0));
|
||||
}
|
||||
});
|
||||
|
||||
return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, authorizationUri,
|
||||
requestedScopes, additionalParameters);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web.authentication;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Attempts to extract an Access Token Request from {@link HttpServletRequest} for the
|
||||
* OAuth 2.0 Device Authorization Grant and then converts it to an
|
||||
* {@link OAuth2DeviceCodeAuthenticationToken} used for authenticating the
|
||||
* authorization grant.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AuthenticationConverter
|
||||
* @see OAuth2DeviceCodeAuthenticationToken
|
||||
* @see OAuth2DeviceAuthorizationEndpointFilter
|
||||
*/
|
||||
public final class OAuth2DeviceCodeAuthenticationConverter implements AuthenticationConverter {
|
||||
|
||||
@Override
|
||||
public Authentication convert(HttpServletRequest request) {
|
||||
// grant_type (REQUIRED)
|
||||
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
|
||||
if (!AuthorizationGrantType.DEVICE_CODE.getValue().equals(grantType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||||
|
||||
// device_code (REQUIRED)
|
||||
String deviceCode = parameters.getFirst(OAuth2ParameterNames.DEVICE_CODE);
|
||||
if (!StringUtils.hasText(deviceCode) || parameters.get(OAuth2ParameterNames.DEVICE_CODE).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(
|
||||
OAuth2ErrorCodes.INVALID_REQUEST,
|
||||
OAuth2ParameterNames.DEVICE_CODE,
|
||||
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
|
||||
}
|
||||
|
||||
Map<String, Object> additionalParameters = new HashMap<>();
|
||||
parameters.forEach((key, value) -> {
|
||||
if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) &&
|
||||
!key.equals(OAuth2ParameterNames.CLIENT_ID) &&
|
||||
!key.equals(OAuth2ParameterNames.DEVICE_CODE)) {
|
||||
additionalParameters.put(key, value.get(0));
|
||||
}
|
||||
});
|
||||
|
||||
return new OAuth2DeviceCodeAuthenticationToken(deviceCode, clientPrincipal, additionalParameters);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.web.authentication;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Attempts to extract a user code from {@link HttpServletRequest} for the
|
||||
* OAuth 2.0 Device Authorization Grant and then converts it to an
|
||||
* {@link OAuth2DeviceVerificationAuthenticationToken} used for authenticating
|
||||
* the request.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
* @see AuthenticationConverter
|
||||
* @see OAuth2DeviceVerificationAuthenticationToken
|
||||
* @see OAuth2DeviceVerificationEndpointFilter
|
||||
*/
|
||||
public final class OAuth2DeviceVerificationAuthenticationConverter implements AuthenticationConverter {
|
||||
|
||||
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3";
|
||||
private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken(
|
||||
"anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
|
||||
@Override
|
||||
public Authentication convert(HttpServletRequest request) {
|
||||
if (!("GET".equals(request.getMethod()) || "POST".equals(request.getMethod()))) {
|
||||
return null;
|
||||
}
|
||||
if (request.getParameter(OAuth2ParameterNames.STATE) != null
|
||||
|| request.getParameter(OAuth2ParameterNames.USER_CODE) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
|
||||
|
||||
// user_code (REQUIRED)
|
||||
String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE);
|
||||
if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) {
|
||||
OAuth2EndpointUtils.throwError(
|
||||
OAuth2ErrorCodes.INVALID_REQUEST,
|
||||
OAuth2ParameterNames.USER_CODE,
|
||||
ERROR_URI);
|
||||
}
|
||||
|
||||
Authentication principal = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (principal == null) {
|
||||
principal = ANONYMOUS_AUTHENTICATION;
|
||||
}
|
||||
|
||||
Map<String, Object> additionalParameters = new HashMap<>();
|
||||
parameters.forEach((key, value) -> {
|
||||
if (!key.equals(OAuth2ParameterNames.USER_CODE)) {
|
||||
additionalParameters.put(key, value.get(0));
|
||||
}
|
||||
});
|
||||
|
||||
return new OAuth2DeviceVerificationAuthenticationToken(principal,
|
||||
OAuth2EndpointUtils.normalizeUserCode(userCode), additionalParameters);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -26,6 +26,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
@@ -81,4 +82,12 @@ final class OAuth2EndpointUtils {
|
||||
throw new OAuth2AuthenticationException(error);
|
||||
}
|
||||
|
||||
static String normalizeUserCode(String userCode) {
|
||||
Assert.hasText(userCode, "userCode cannot be empty");
|
||||
StringBuilder sb = new StringBuilder(userCode.toUpperCase().replaceAll("[^A-Z\\d]+", ""));
|
||||
Assert.isTrue(sb.length() == 8, "userCode must be exactly 8 alpha/numeric characters");
|
||||
sb.insert(4, '-');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,13 +24,12 @@ import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@@ -72,6 +71,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider;
|
||||
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
|
||||
@@ -90,6 +90,7 @@ import org.springframework.security.oauth2.server.authorization.web.authenticati
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -291,7 +292,8 @@ public class OAuth2ClientCredentialsGrantTests {
|
||||
converter == authenticationConverter ||
|
||||
converter instanceof OAuth2AuthorizationCodeAuthenticationConverter ||
|
||||
converter instanceof OAuth2RefreshTokenAuthenticationConverter ||
|
||||
converter instanceof OAuth2ClientCredentialsAuthenticationConverter);
|
||||
converter instanceof OAuth2ClientCredentialsAuthenticationConverter ||
|
||||
converter instanceof OAuth2DeviceCodeAuthenticationConverter);
|
||||
|
||||
verify(authenticationProvider).authenticate(eq(clientCredentialsAuthentication));
|
||||
|
||||
@@ -303,7 +305,8 @@ public class OAuth2ClientCredentialsGrantTests {
|
||||
provider == authenticationProvider ||
|
||||
provider instanceof OAuth2AuthorizationCodeAuthenticationProvider ||
|
||||
provider instanceof OAuth2RefreshTokenAuthenticationProvider ||
|
||||
provider instanceof OAuth2ClientCredentialsAuthenticationProvider);
|
||||
provider instanceof OAuth2ClientCredentialsAuthenticationProvider ||
|
||||
provider instanceof OAuth2DeviceCodeAuthenticationProvider);
|
||||
|
||||
verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(accessTokenAuthentication));
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ public class AuthorizationServerSettingsTests {
|
||||
.settings(settings -> settings.put("name2", "value2"))
|
||||
.build();
|
||||
|
||||
assertThat(authorizationServerSettings.getSettings()).hasSize(10);
|
||||
assertThat(authorizationServerSettings.getSettings()).hasSize(12);
|
||||
assertThat(authorizationServerSettings.<String>getSetting("name1")).isEqualTo("value1");
|
||||
assertThat(authorizationServerSettings.<String>getSetting("name2")).isEqualTo("value2");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2022 the original author or authors.
|
||||
* Copyright 2020-2023 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.
|
||||
@@ -34,8 +34,9 @@ public class TokenSettingsTests {
|
||||
@Test
|
||||
public void buildWhenDefaultThenDefaultsAreSet() {
|
||||
TokenSettings tokenSettings = TokenSettings.builder().build();
|
||||
assertThat(tokenSettings.getSettings()).hasSize(6);
|
||||
assertThat(tokenSettings.getSettings()).hasSize(7);
|
||||
assertThat(tokenSettings.getAuthorizationCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5));
|
||||
assertThat(tokenSettings.getDeviceCodeTimeToLive()).isEqualTo(Duration.ofMinutes(30));
|
||||
assertThat(tokenSettings.getAccessTokenTimeToLive()).isEqualTo(Duration.ofMinutes(5));
|
||||
assertThat(tokenSettings.getAccessTokenFormat()).isEqualTo(OAuth2TokenFormat.SELF_CONTAINED);
|
||||
assertThat(tokenSettings.isReuseRefreshTokens()).isTrue();
|
||||
@@ -163,7 +164,7 @@ public class TokenSettingsTests {
|
||||
.setting("name1", "value1")
|
||||
.settings(settings -> settings.put("name2", "value2"))
|
||||
.build();
|
||||
assertThat(tokenSettings.getSettings()).hasSize(8);
|
||||
assertThat(tokenSettings.getSettings()).hasSize(9);
|
||||
assertThat(tokenSettings.<String>getSetting("name1")).isEqualTo("value1");
|
||||
assertThat(tokenSettings.<String>getSetting("name2")).isEqualTo("value2");
|
||||
}
|
||||
|
||||
27
samples/device-client/samples-device-client.gradle
Normal file
27
samples/device-client/samples-device-client.gradle
Normal file
@@ -0,0 +1,27 @@
|
||||
plugins {
|
||||
id "org.springframework.boot" version "3.0.0"
|
||||
id "io.spring.dependency-management" version "1.0.11.RELEASE"
|
||||
id "java"
|
||||
}
|
||||
|
||||
group = project.rootProject.group
|
||||
version = project.rootProject.version
|
||||
sourceCompatibility = "17"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.springframework.boot:spring-boot-starter-web"
|
||||
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
|
||||
implementation "org.springframework.boot:spring-boot-starter-security"
|
||||
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
|
||||
implementation "org.springframework:spring-webflux"
|
||||
implementation "org.webjars:webjars-locator-core"
|
||||
implementation "org.webjars:bootstrap:3.4.1"
|
||||
implementation "org.webjars:jquery:3.4.1"
|
||||
|
||||
testImplementation "org.springframework.boot:spring-boot-starter-test"
|
||||
testImplementation "org.springframework.security:spring-security-test"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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 Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class DeviceClientApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DeviceClientApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
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.WebSecurityCustomizer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.requestMatchers("/", "/authorize").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.exceptionHandling((exceptions) -> exceptions
|
||||
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/"))
|
||||
)
|
||||
.oauth2Client(Customizer.withDefaults());
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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 sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
|
||||
import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Bean
|
||||
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
|
||||
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
|
||||
// @formatter:off
|
||||
return WebClient.builder()
|
||||
.apply(oauth2Client.oauth2Configuration())
|
||||
.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OAuth2AuthorizedClientManager authorizedClientManager(
|
||||
ClientRegistrationRepository clientRegistrationRepository,
|
||||
OAuth2AuthorizedClientRepository authorizedClientRepository) {
|
||||
|
||||
OAuth2AuthorizedClientProvider authorizedClientProvider =
|
||||
OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
.provider(new DeviceCodeOAuth2AuthorizedClientProvider())
|
||||
.authorizationCode()
|
||||
.refreshToken()
|
||||
.build();
|
||||
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
|
||||
new DefaultOAuth2AuthorizedClientManager(
|
||||
clientRegistrationRepository, authorizedClientRepository);
|
||||
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
|
||||
// Set a contextAttributesMapper to obtain device_code from the request
|
||||
authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider
|
||||
.deviceCodeContextAttributesMapper());
|
||||
|
||||
return authorizedClientManager;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@Controller
|
||||
public class DeviceController {
|
||||
|
||||
private static final ParameterizedTypeReference<Map<String, Object>> TYPE_REFERENCE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
private final String messagesBaseUri;
|
||||
|
||||
private final SecurityContextRepository securityContextRepository =
|
||||
new HttpSessionSecurityContextRepository();
|
||||
|
||||
private final SecurityContextHolderStrategy securityContextHolderStrategy =
|
||||
SecurityContextHolder.getContextHolderStrategy();
|
||||
|
||||
public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient,
|
||||
@Value("${messages.base-uri}") String messagesBaseUri) {
|
||||
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.webClient = webClient;
|
||||
this.messagesBaseUri = messagesBaseUri;
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
public String index() {
|
||||
return "index";
|
||||
}
|
||||
|
||||
@GetMapping("/authorize")
|
||||
public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) {
|
||||
// @formatter:off
|
||||
ClientRegistration clientRegistration =
|
||||
this.clientRegistrationRepository.findByRegistrationId(
|
||||
"messaging-client-device-grant");
|
||||
// @formatter:on
|
||||
|
||||
MultiValueMap<String, String> requestParameters = new LinkedMultiValueMap<>();
|
||||
requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
|
||||
requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(
|
||||
clientRegistration.getScopes(), " "));
|
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> responseParameters =
|
||||
this.webClient.post()
|
||||
.uri(clientRegistration.getProviderDetails().getAuthorizationUri())
|
||||
.headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(),
|
||||
clientRegistration.getClientSecret()))
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(BodyInserters.fromFormData(requestParameters))
|
||||
.retrieve()
|
||||
.bodyToMono(TYPE_REFERENCE)
|
||||
.block();
|
||||
// @formatter:on
|
||||
|
||||
Objects.requireNonNull(responseParameters, "Device Authorization Response cannot be null");
|
||||
Instant issuedAt = Instant.now();
|
||||
Integer expiresIn = (Integer) responseParameters.get(OAuth2ParameterNames.EXPIRES_IN);
|
||||
Instant expiresAt = issuedAt.plusSeconds(expiresIn);
|
||||
String deviceCodeValue = (String) responseParameters.get(OAuth2ParameterNames.DEVICE_CODE);
|
||||
|
||||
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, issuedAt, expiresAt);
|
||||
saveSecurityContext(deviceCode, request, response);
|
||||
|
||||
model.addAttribute("deviceCode", deviceCode.getTokenValue());
|
||||
model.addAttribute("expiresAt", deviceCode.getExpiresAt());
|
||||
model.addAttribute("userCode", responseParameters.get(OAuth2ParameterNames.USER_CODE));
|
||||
model.addAttribute("verificationUri", responseParameters.get(OAuth2ParameterNames.VERIFICATION_URI));
|
||||
// Note: You could use a QR-code to display this URL
|
||||
model.addAttribute("verificationUriComplete", responseParameters.get(
|
||||
OAuth2ParameterNames.VERIFICATION_URI_COMPLETE));
|
||||
|
||||
return "authorize";
|
||||
}
|
||||
|
||||
/**
|
||||
* @see DeviceControllerAdvice
|
||||
*/
|
||||
@PostMapping("/authorize")
|
||||
public ResponseEntity<Void> poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode,
|
||||
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
|
||||
OAuth2AuthorizedClient authorizedClient) {
|
||||
|
||||
// The client will repeatedly poll until authorization is granted.
|
||||
//
|
||||
// The OAuth2AuthorizedClientManager uses the device_code parameter
|
||||
// to make a token request, which returns authorization_pending until
|
||||
// the user has granted authorization.
|
||||
//
|
||||
// If the user has denied authorization, access_denied is returned and
|
||||
// polling should stop.
|
||||
//
|
||||
// If the device code expires, expired_token is returned and polling
|
||||
// should stop.
|
||||
//
|
||||
// This endpoint simply returns 200 OK when client is authorized.
|
||||
return ResponseEntity.status(HttpStatus.OK).build();
|
||||
}
|
||||
|
||||
@GetMapping("/authorized")
|
||||
public String authorized(Model model,
|
||||
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
|
||||
OAuth2AuthorizedClient authorizedClient) {
|
||||
|
||||
String[] messages = this.webClient.get()
|
||||
.uri(this.messagesBaseUri)
|
||||
.attributes(oauth2AuthorizedClient(authorizedClient))
|
||||
.retrieve()
|
||||
.bodyToMono(String[].class)
|
||||
.block();
|
||||
model.addAttribute("messages", messages);
|
||||
|
||||
return "authorized";
|
||||
}
|
||||
|
||||
private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
|
||||
// @formatter:off
|
||||
UsernamePasswordAuthenticationToken deviceAuthentication =
|
||||
UsernamePasswordAuthenticationToken.authenticated(
|
||||
deviceCode, null, AuthorityUtils.createAuthorityList("ROLE_DEVICE"));
|
||||
// @formatter:on
|
||||
|
||||
SecurityContext securityContext = this.securityContextHolderStrategy.createEmptyContext();
|
||||
securityContext.setAuthentication(deviceAuthentication);
|
||||
this.securityContextHolderStrategy.setContext(securityContext);
|
||||
this.securityContextRepository.saveContext(securityContext, request, response);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class DeviceControllerAdvice {
|
||||
|
||||
private static final Set<String> DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList(
|
||||
"authorization_pending",
|
||||
"slow_down",
|
||||
"access_denied",
|
||||
"expired_token"
|
||||
));
|
||||
|
||||
@ExceptionHandler(OAuth2AuthorizationException.class)
|
||||
public ResponseEntity<OAuth2Error> handleError(OAuth2AuthorizationException ex) {
|
||||
String errorCode = ex.getError().getErrorCode();
|
||||
if (DEVICE_GRANT_ERRORS.contains(errorCode)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getError());
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getError());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.security.oauth2.client.ClientAuthorizationException;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizationContext;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider {
|
||||
|
||||
private OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> accessTokenResponseClient =
|
||||
new OAuth2DeviceAccessTokenResponseClient();
|
||||
|
||||
private Duration clockSkew = Duration.ofSeconds(60);
|
||||
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
public DeviceCodeOAuth2AuthorizedClientProvider() {
|
||||
}
|
||||
|
||||
public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> accessTokenResponseClient) {
|
||||
this.accessTokenResponseClient = accessTokenResponseClient;
|
||||
}
|
||||
|
||||
public void setClockSkew(Duration clockSkew) {
|
||||
this.clockSkew = clockSkew;
|
||||
}
|
||||
|
||||
public void setClock(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
|
||||
Assert.notNull(context, "context cannot be null");
|
||||
ClientRegistration clientRegistration = context.getClientRegistration();
|
||||
if (!AuthorizationGrantType.DEVICE_CODE.equals(clientRegistration.getAuthorizationGrantType())) {
|
||||
return null;
|
||||
}
|
||||
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
|
||||
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
|
||||
// If client is already authorized but access token is NOT expired than no
|
||||
// need for re-authorization
|
||||
return null;
|
||||
}
|
||||
if (authorizedClient != null && authorizedClient.getRefreshToken() != null) {
|
||||
// If client is already authorized but access token is expired and a
|
||||
// refresh token is available, delegate to refresh_token.
|
||||
return null;
|
||||
}
|
||||
// *****************************************************************
|
||||
// Get device_code set via DefaultOAuth2AuthorizedClientManager#setContextAttributesMapper()
|
||||
// *****************************************************************
|
||||
String deviceCode = context.getAttribute(OAuth2ParameterNames.DEVICE_CODE);
|
||||
// Attempt to authorize the client, which will repeatedly fail until the user grants authorization
|
||||
OAuth2DeviceGrantRequest deviceGrantRequest = new OAuth2DeviceGrantRequest(clientRegistration, deviceCode);
|
||||
OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, deviceGrantRequest);
|
||||
return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
|
||||
tokenResponse.getAccessToken(), tokenResponse.getRefreshToken());
|
||||
}
|
||||
|
||||
private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration,
|
||||
OAuth2DeviceGrantRequest deviceGrantRequest) {
|
||||
try {
|
||||
return this.accessTokenResponseClient.getTokenResponse(deviceGrantRequest);
|
||||
} catch (OAuth2AuthorizationException ex) {
|
||||
throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasTokenExpired(OAuth2Token token) {
|
||||
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
|
||||
}
|
||||
|
||||
public static Function<OAuth2AuthorizeRequest, Map<String, Object>> deviceCodeContextAttributesMapper() {
|
||||
return (authorizeRequest) -> {
|
||||
HttpServletRequest request = authorizeRequest.getAttribute(HttpServletRequest.class.getName());
|
||||
Assert.notNull(request, "request cannot be null");
|
||||
|
||||
// Obtain device code from request
|
||||
String deviceCode = request.getParameter(OAuth2ParameterNames.DEVICE_CODE);
|
||||
return (deviceCode != null) ? Collections.singletonMap(OAuth2ParameterNames.DEVICE_CODE, deviceCode) :
|
||||
Collections.emptyMap();
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.http.converter.FormHttpMessageConverter;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestOperations;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
public final class OAuth2DeviceAccessTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> {
|
||||
|
||||
private RestOperations restOperations;
|
||||
|
||||
public OAuth2DeviceAccessTokenResponseClient() {
|
||||
RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(),
|
||||
new OAuth2AccessTokenResponseHttpMessageConverter()));
|
||||
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
|
||||
this.restOperations = restTemplate;
|
||||
}
|
||||
|
||||
public void setRestOperations(RestOperations restOperations) {
|
||||
this.restOperations = restOperations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceGrantRequest deviceGrantRequest) {
|
||||
ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration();
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret());
|
||||
|
||||
MultiValueMap<String, Object> requestParameters = new LinkedMultiValueMap<>();
|
||||
requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, deviceGrantRequest.getGrantType().getValue());
|
||||
requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
|
||||
requestParameters.add(OAuth2ParameterNames.DEVICE_CODE, deviceGrantRequest.getDeviceCode());
|
||||
|
||||
// @formatter:off
|
||||
RequestEntity<MultiValueMap<String, Object>> requestEntity =
|
||||
RequestEntity.post(deviceGrantRequest.getClientRegistration().getProviderDetails().getTokenUri())
|
||||
.headers(headers)
|
||||
.body(requestParameters);
|
||||
// @formatter:on
|
||||
|
||||
try {
|
||||
return this.restOperations.exchange(requestEntity, OAuth2AccessTokenResponse.class).getBody();
|
||||
} catch (RestClientException ex) {
|
||||
OAuth2Error oauth2Error = new OAuth2Error("invalid_token_response",
|
||||
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
|
||||
+ ex.getMessage(), null);
|
||||
throw new OAuth2AuthorizationException(oauth2Error, ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.authentication;
|
||||
|
||||
import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
public final class OAuth2DeviceGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
|
||||
|
||||
private final String deviceCode;
|
||||
|
||||
public OAuth2DeviceGrantRequest(ClientRegistration clientRegistration, String deviceCode) {
|
||||
super(AuthorizationGrantType.DEVICE_CODE, clientRegistration);
|
||||
Assert.hasText(deviceCode, "deviceCode cannot be empty");
|
||||
this.deviceCode = deviceCode;
|
||||
}
|
||||
|
||||
public String getDeviceCode() {
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
}
|
||||
29
samples/device-client/src/main/resources/application.yml
Normal file
29
samples/device-client/src/main/resources/application.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
org.springframework.security: trace
|
||||
|
||||
spring:
|
||||
thymeleaf:
|
||||
cache: false
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
messaging-client-device-grant:
|
||||
provider: spring
|
||||
client-id: messaging-client
|
||||
client-secret: secret
|
||||
authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code
|
||||
scope: message.read,message.write
|
||||
client-name: messaging-client-device-grant
|
||||
provider:
|
||||
spring:
|
||||
issuer-uri: http://localhost:9000
|
||||
authorization-uri: ${spring.security.oauth2.client.provider.spring.issuer-uri}/oauth2/device_authorization
|
||||
|
||||
messages:
|
||||
base-uri: http://127.0.0.1:8090/messages
|
||||
@@ -0,0 +1,13 @@
|
||||
html, body, .container, .jumbotron {
|
||||
height: 100%;
|
||||
}
|
||||
.jumbotron {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.gap {
|
||||
margin-top: 70px;
|
||||
}
|
||||
.code {
|
||||
font-size: 2em;
|
||||
letter-spacing: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>Device Activation</h2>
|
||||
<p>Please visit <a th:href="${verificationUri}" th:text="${verificationUri?.replaceFirst('https?://', '')}"></a> on another device to continue.</p>
|
||||
<p class="gap">Activation Code</p>
|
||||
<div class="well">
|
||||
<span class="code" th:text="${userCode}"></span>
|
||||
<form id="authorize-form" th:action="@{/authorize}" method="post">
|
||||
<input type="hidden" id="device_code" name="device_code" th:value="${deviceCode}" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/webjars/jquery/jquery.min.js" th:src="@{/webjars/jquery/jquery.min.js}"></script>
|
||||
<script type="text/javascript">
|
||||
function authorize() {
|
||||
let deviceCode = $('#device_code').val();
|
||||
let csrfToken = $('[name=_csrf]').val();
|
||||
if (deviceCode) {
|
||||
$.ajax({
|
||||
url: '/authorize',
|
||||
method: 'POST',
|
||||
data: {
|
||||
device_code: deviceCode,
|
||||
_csrf: csrfToken
|
||||
},
|
||||
timeout: 0
|
||||
}).fail((err) => {
|
||||
let response = err.responseJSON;
|
||||
if (response.errorCode === 'authorization_pending') {
|
||||
console.log('authorization pending, continuing to poll...');
|
||||
} else if (response.errorCode === 'slow_down') {
|
||||
console.log('slowing down...');
|
||||
slowDown();
|
||||
} else if (response.errorCode === 'token_expired') {
|
||||
console.log('token expired, stopping...');
|
||||
clear();
|
||||
location.href = '/';
|
||||
} else if (response.errorCode === 'access_denied') {
|
||||
console.log('access denied, stopping...');
|
||||
clear();
|
||||
location.href = '/';
|
||||
}
|
||||
}).done(() => window.location.href = '/authorized');
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
authorize.handler = window.setInterval(authorize, authorize.interval * 1000);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (authorize.handler !== null) {
|
||||
window.clearInterval(authorize.handler);
|
||||
}
|
||||
}
|
||||
|
||||
function slowDown() {
|
||||
authorize.interval += 5;
|
||||
clear();
|
||||
schedule();
|
||||
}
|
||||
|
||||
authorize.interval = 5;
|
||||
authorize.handler = null;
|
||||
|
||||
window.addEventListener('load', schedule);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>Success!</h2>
|
||||
<p>This device has been activated.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
<div class="col-md-12" th:if="${messages}">
|
||||
<h4>Messages:</h4>
|
||||
<table class="table table-condensed">
|
||||
<tbody>
|
||||
<tr class="row" th:each="message : ${messages}">
|
||||
<td th:text="${message}">message</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>Activation Required</h2>
|
||||
<p>You must activate this device. Please log in to continue.</p>
|
||||
<a th:href="@{/authorize}" class="btn btn-primary" role="button">Log In</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id "org.springframework.boot" version "3.0.0"
|
||||
id "io.spring.dependency-management" version "1.0.11.RELEASE"
|
||||
id "java"
|
||||
}
|
||||
|
||||
group = project.rootProject.group
|
||||
version = project.rootProject.version
|
||||
sourceCompatibility = "17"
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = "https://repo.spring.io/snapshot"
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
// Temporarily use SNAPSHOT version
|
||||
// TODO: Use 6.1.0-M2 version after release
|
||||
ext["spring-security.version"] = "6.1.0-SNAPSHOT"
|
||||
|
||||
dependencies {
|
||||
implementation "org.springframework.boot:spring-boot-starter-web"
|
||||
implementation "org.springframework.boot:spring-boot-starter-security"
|
||||
implementation "org.springframework.boot:spring-boot-starter-jdbc"
|
||||
implementation project(":spring-security-oauth2-authorization-server")
|
||||
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
|
||||
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
|
||||
implementation "org.springframework:spring-webflux"
|
||||
implementation "org.webjars:webjars-locator-core"
|
||||
implementation "org.webjars:bootstrap:3.4.1"
|
||||
implementation "org.webjars:jquery:3.4.1"
|
||||
runtimeOnly "com.h2database:h2"
|
||||
|
||||
testImplementation "org.springframework.boot:spring-boot-starter-test"
|
||||
testImplementation "org.springframework.security:spring-security-test"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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 Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class DeviceGrantAuthorizationServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DeviceGrantAuthorizationServerApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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 java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
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.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
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.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
|
||||
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.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
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.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
|
||||
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
|
||||
.deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint
|
||||
.verificationUri("/activate")
|
||||
)
|
||||
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
|
||||
|
||||
// @formatter:off
|
||||
http
|
||||
.exceptionHandling((exceptions) -> exceptions
|
||||
.authenticationEntryPoint(
|
||||
new LoginUrlAuthenticationEntryPoint("/login"))
|
||||
)
|
||||
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
// @formatter:off
|
||||
http
|
||||
.authorizeHttpRequests((authorize) -> authorize
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin(Customizer.withDefaults());
|
||||
// @formatter:on
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
// @formatter:off
|
||||
UserDetails userDetails = User.withDefaultPasswordEncoder()
|
||||
.username("user")
|
||||
.password("password")
|
||||
.roles("USER")
|
||||
.build();
|
||||
// @formatter:on
|
||||
|
||||
return new InMemoryUserDetailsManager(userDetails);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RegisteredClientRepository registeredClientRepository() {
|
||||
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("messaging-client")
|
||||
.clientSecret("{noop}secret")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
|
||||
.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(OidcScopes.PROFILE)
|
||||
.scope("message.read")
|
||||
.scope("message.write")
|
||||
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
|
||||
.build();
|
||||
|
||||
return new InMemoryRegisteredClientRepository(registeredClient);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JWKSource<SecurityContext> jwkSource() {
|
||||
KeyPair keyPair = generateRsaKey();
|
||||
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
|
||||
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
|
||||
RSAKey rsaKey = new RSAKey.Builder(publicKey)
|
||||
.privateKey(privateKey)
|
||||
.keyID(UUID.randomUUID().toString())
|
||||
.build();
|
||||
JWKSet jwkSet = new JWKSet(rsaKey);
|
||||
return new ImmutableJWKSet<>(jwkSet);
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
|
||||
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder().build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2002-2023 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 org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
*/
|
||||
@Controller
|
||||
public class DeviceController {
|
||||
|
||||
@GetMapping("/activate")
|
||||
public String activate(@RequestParam(value = "user_code", required = false) String userCode) {
|
||||
if (userCode != null) {
|
||||
return "redirect:/oauth2/device_verification?user_code=" + userCode;
|
||||
}
|
||||
return "activate";
|
||||
}
|
||||
|
||||
@GetMapping("/activated")
|
||||
public String activated() {
|
||||
return "activated";
|
||||
}
|
||||
|
||||
@GetMapping(value = "/", params = "success")
|
||||
public String success() {
|
||||
return "activated";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2020-2023 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.util.Map;
|
||||
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.web.servlet.error.ErrorController;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
/**
|
||||
* @author Steve Riesenberg
|
||||
* @since 1.1
|
||||
*/
|
||||
@Controller
|
||||
public class DeviceErrorController implements ErrorController {
|
||||
|
||||
@RequestMapping("/error")
|
||||
public ModelAndView handleError(HttpServletRequest request) {
|
||||
String message = getErrorMessage(request);
|
||||
if (message.startsWith("[access_denied]")) {
|
||||
return new ModelAndView("access-denied");
|
||||
}
|
||||
return new ModelAndView("error", Map.of("message", message));
|
||||
}
|
||||
|
||||
private String getErrorMessage(HttpServletRequest request) {
|
||||
return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
server:
|
||||
port: 9000
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: trace
|
||||
@@ -0,0 +1,13 @@
|
||||
html, body, .container, .jumbotron {
|
||||
height: 100%;
|
||||
}
|
||||
.jumbotron {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.gap {
|
||||
margin-top: 70px;
|
||||
}
|
||||
.code {
|
||||
font-size: 2em;
|
||||
letter-spacing: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>Access Denied</h2>
|
||||
<p>You have denied access. Please return to your device to continue.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<form th:action="@{/oauth2/device_verification}" method="get">
|
||||
<h2>Device Activation</h2>
|
||||
<p>Enter the activation code to authorize the device.</p>
|
||||
<p class="gap">Activation Code</p>
|
||||
<div class="form-group">
|
||||
<label class="sr-only" for="user_code">Activation Code</label>
|
||||
<input type="text" class="form-control" id="user_code" name="user_code" placeholder="Activation Code" autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-default">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>Success!</h2>
|
||||
<p>You have successfully activated your device. Please return to your device to continue.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Device Grant Example</title>
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
|
||||
<link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="jumbotron">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>Error</h2>
|
||||
<p th:text="${message}"></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user