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
@@ -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>
|
||||
Reference in New Issue
Block a user