Skip to content

Commit 92b8b85

Browse files
author
Steve Riesenberg
committed
Add device client sample
1 parent aca45e0 commit 92b8b85

File tree

15 files changed

+878
-0
lines changed

15 files changed

+878
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
plugins {
2+
id "org.springframework.boot" version "3.0.0"
3+
id "io.spring.dependency-management" version "1.0.11.RELEASE"
4+
id "java"
5+
}
6+
7+
group = project.rootProject.group
8+
version = project.rootProject.version
9+
sourceCompatibility = "17"
10+
11+
repositories {
12+
maven {
13+
url = "https://repo.spring.io/snapshot"
14+
}
15+
mavenCentral()
16+
}
17+
18+
// Temporarily use SNAPSHOT version
19+
// TODO: Use 6.1.0-M2 version after release
20+
ext["spring-security.version"] = "6.1.0-SNAPSHOT"
21+
22+
dependencies {
23+
implementation "org.springframework.boot:spring-boot-starter-web"
24+
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
25+
implementation "org.springframework.boot:spring-boot-starter-security"
26+
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
27+
implementation "org.springframework:spring-webflux"
28+
//implementation "io.projectreactor.netty:reactor-netty"
29+
implementation "org.webjars:webjars-locator-core"
30+
implementation "org.webjars:bootstrap:3.4.1"
31+
implementation "org.webjars:jquery:3.4.1"
32+
33+
testImplementation "org.springframework.boot:spring-boot-starter-test"
34+
testImplementation "org.springframework.security:spring-security-test"
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample;
17+
18+
import org.springframework.boot.SpringApplication;
19+
import org.springframework.boot.autoconfigure.SpringBootApplication;
20+
21+
/**
22+
* @author Steve Riesenberg
23+
* @since 1.1
24+
*/
25+
@SpringBootApplication
26+
public class DeviceClientApplication {
27+
28+
public static void main(String[] args) {
29+
SpringApplication.run(DeviceClientApplication.class, args);
30+
}
31+
32+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.config;
17+
18+
import org.springframework.context.annotation.Bean;
19+
import org.springframework.context.annotation.Configuration;
20+
import org.springframework.security.config.Customizer;
21+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
22+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
23+
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
24+
import org.springframework.security.web.SecurityFilterChain;
25+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
26+
27+
/**
28+
* @author Steve Riesenberg
29+
* @since 1.1
30+
*/
31+
@Configuration
32+
@EnableWebSecurity
33+
public class SecurityConfig {
34+
35+
@Bean
36+
public WebSecurityCustomizer webSecurityCustomizer() {
37+
return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**");
38+
}
39+
40+
@Bean
41+
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
42+
// @formatter:off
43+
http
44+
.authorizeHttpRequests((authorize) -> authorize
45+
.requestMatchers("/", "/authorize").permitAll()
46+
.anyRequest().authenticated()
47+
)
48+
.exceptionHandling((exceptions) -> exceptions
49+
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/"))
50+
)
51+
.oauth2Client(Customizer.withDefaults());
52+
// @formatter:on
53+
return http.build();
54+
}
55+
56+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.config;
17+
18+
import sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider;
19+
20+
import org.springframework.context.annotation.Bean;
21+
import org.springframework.context.annotation.Configuration;
22+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
23+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
24+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
25+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
26+
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
27+
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
28+
import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction;
29+
import org.springframework.web.reactive.function.client.WebClient;
30+
31+
/**
32+
* @author Steve Riesenberg
33+
* @since 1.1
34+
*/
35+
@Configuration
36+
public class WebClientConfig {
37+
38+
@Bean
39+
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
40+
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
41+
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
42+
// @formatter:off
43+
return WebClient.builder()
44+
.apply(oauth2Client.oauth2Configuration())
45+
.build();
46+
// @formatter:on
47+
}
48+
49+
@Bean
50+
public OAuth2AuthorizedClientManager authorizedClientManager(
51+
ClientRegistrationRepository clientRegistrationRepository,
52+
OAuth2AuthorizedClientRepository authorizedClientRepository) {
53+
54+
OAuth2AuthorizedClientProvider authorizedClientProvider =
55+
OAuth2AuthorizedClientProviderBuilder.builder()
56+
.provider(new DeviceCodeOAuth2AuthorizedClientProvider())
57+
.authorizationCode()
58+
.refreshToken()
59+
.build();
60+
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
61+
new DefaultOAuth2AuthorizedClientManager(
62+
clientRegistrationRepository, authorizedClientRepository);
63+
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
64+
// Set a contextAttributesMapper to obtain device_code from the request
65+
authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider
66+
.deviceCodeContextAttributesMapper());
67+
68+
return authorizedClientManager;
69+
}
70+
71+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.web;
17+
18+
import java.time.Instant;
19+
import java.util.Map;
20+
import java.util.Objects;
21+
22+
import jakarta.servlet.http.HttpServletRequest;
23+
import jakarta.servlet.http.HttpServletResponse;
24+
25+
import org.springframework.beans.factory.annotation.Value;
26+
import org.springframework.core.ParameterizedTypeReference;
27+
import org.springframework.http.HttpStatus;
28+
import org.springframework.http.MediaType;
29+
import org.springframework.http.ResponseEntity;
30+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
31+
import org.springframework.security.core.authority.AuthorityUtils;
32+
import org.springframework.security.core.context.SecurityContext;
33+
import org.springframework.security.core.context.SecurityContextHolder;
34+
import org.springframework.security.core.context.SecurityContextHolderStrategy;
35+
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
36+
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
37+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
38+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
39+
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
40+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
41+
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
42+
import org.springframework.security.web.context.SecurityContextRepository;
43+
import org.springframework.stereotype.Controller;
44+
import org.springframework.ui.Model;
45+
import org.springframework.util.LinkedMultiValueMap;
46+
import org.springframework.util.MultiValueMap;
47+
import org.springframework.util.StringUtils;
48+
import org.springframework.web.bind.annotation.GetMapping;
49+
import org.springframework.web.bind.annotation.PostMapping;
50+
import org.springframework.web.bind.annotation.RequestParam;
51+
import org.springframework.web.reactive.function.BodyInserters;
52+
import org.springframework.web.reactive.function.client.WebClient;
53+
54+
import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient;
55+
56+
/**
57+
* @author Steve Riesenberg
58+
* @since 1.1
59+
*/
60+
@Controller
61+
public class DeviceController {
62+
63+
private static final ParameterizedTypeReference<Map<String, Object>> TYPE_REFERENCE =
64+
new ParameterizedTypeReference<>() {};
65+
66+
private final ClientRegistrationRepository clientRegistrationRepository;
67+
68+
private final WebClient webClient;
69+
70+
private final String messagesBaseUri;
71+
72+
private final SecurityContextRepository securityContextRepository =
73+
new HttpSessionSecurityContextRepository();
74+
75+
private final SecurityContextHolderStrategy securityContextHolderStrategy =
76+
SecurityContextHolder.getContextHolderStrategy();
77+
78+
public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient,
79+
@Value("${messages.base-uri}") String messagesBaseUri) {
80+
81+
this.clientRegistrationRepository = clientRegistrationRepository;
82+
this.webClient = webClient;
83+
this.messagesBaseUri = messagesBaseUri;
84+
}
85+
86+
@GetMapping("/")
87+
public String index() {
88+
return "index";
89+
}
90+
91+
@GetMapping("/authorize")
92+
public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) {
93+
// @formatter:off
94+
ClientRegistration clientRegistration =
95+
this.clientRegistrationRepository.findByRegistrationId(
96+
"messaging-client-device-grant");
97+
// @formatter:on
98+
99+
MultiValueMap<String, String> requestParameters = new LinkedMultiValueMap<>();
100+
requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
101+
requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(
102+
clientRegistration.getScopes(), " "));
103+
104+
// @formatter:off
105+
Map<String, Object> responseParameters =
106+
this.webClient.post()
107+
.uri(clientRegistration.getProviderDetails().getAuthorizationUri())
108+
.headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(),
109+
clientRegistration.getClientSecret()))
110+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
111+
.body(BodyInserters.fromFormData(requestParameters))
112+
.retrieve()
113+
.bodyToMono(TYPE_REFERENCE)
114+
.block();
115+
// @formatter:on
116+
117+
Objects.requireNonNull(responseParameters, "Device Authorization Response cannot be null");
118+
Instant issuedAt = Instant.now();
119+
Integer expiresIn = (Integer) responseParameters.get(OAuth2ParameterNames.EXPIRES_IN);
120+
Instant expiresAt = issuedAt.plusSeconds(expiresIn);
121+
String deviceCodeValue = (String) responseParameters.get(OAuth2ParameterNames.DEVICE_CODE);
122+
123+
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, issuedAt, expiresAt);
124+
saveSecurityContext(deviceCode, request, response);
125+
126+
model.addAttribute("deviceCode", deviceCode.getTokenValue());
127+
model.addAttribute("expiresAt", deviceCode.getExpiresAt());
128+
model.addAttribute("userCode", responseParameters.get(OAuth2ParameterNames.USER_CODE));
129+
model.addAttribute("verificationUri", responseParameters.get(OAuth2ParameterNames.VERIFICATION_URI));
130+
// Note: You could use a QR-code to display this URL
131+
model.addAttribute("verificationUriComplete", responseParameters.get(
132+
OAuth2ParameterNames.VERIFICATION_URI_COMPLETE));
133+
134+
return "authorize";
135+
}
136+
137+
/**
138+
* @see DeviceControllerAdvice
139+
*/
140+
@PostMapping("/authorize")
141+
public ResponseEntity<Void> poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode,
142+
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
143+
OAuth2AuthorizedClient authorizedClient) {
144+
145+
// The client will repeatedly poll until authorization is granted.
146+
//
147+
// The OAuth2AuthorizedClientManager uses the device_code parameter
148+
// to make a token request, which returns authorization_pending until
149+
// the user has granted authorization.
150+
//
151+
// If the user has denied authorization, access_denied is returned and
152+
// polling should stop.
153+
//
154+
// If the device code expires, expired_token is returned and polling
155+
// should stop.
156+
//
157+
// This endpoint simply returns 200 OK when client is authorized.
158+
return ResponseEntity.status(HttpStatus.OK).build();
159+
}
160+
161+
@GetMapping("/authorized")
162+
public String authorized(Model model,
163+
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
164+
OAuth2AuthorizedClient authorizedClient) {
165+
166+
String[] messages = this.webClient.get()
167+
.uri(this.messagesBaseUri)
168+
.attributes(oauth2AuthorizedClient(authorizedClient))
169+
.retrieve()
170+
.bodyToMono(String[].class)
171+
.block();
172+
model.addAttribute("messages", messages);
173+
174+
return "authorized";
175+
}
176+
177+
private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request,
178+
HttpServletResponse response) {
179+
180+
// @formatter:off
181+
UsernamePasswordAuthenticationToken deviceAuthentication =
182+
UsernamePasswordAuthenticationToken.authenticated(
183+
deviceCode, null, AuthorityUtils.createAuthorityList("ROLE_DEVICE"));
184+
// @formatter:on
185+
186+
SecurityContext securityContext = this.securityContextHolderStrategy.createEmptyContext();
187+
securityContext.setAuthentication(deviceAuthentication);
188+
this.securityContextHolderStrategy.setContext(securityContext);
189+
this.securityContextRepository.saveContext(securityContext, request, response);
190+
}
191+
192+
}

0 commit comments

Comments
 (0)