在前面的章节中,我们介绍过客户端使用授权码方式获取令牌(token),但是往往有些客户端所在的环境是没有浏览器的。例如:智能电视、媒体控制台、数字相框、打印机等,像这类的设备,要在设备上面将用户操作引导至认证平台界面去输入账号、密码,然后确认授权,这显示不现实。解决这类设备的登录场景,最常见的就是手机扫码登录,或将认证地址发送到手机上,让用户在手机浏览器上进行认证授权。在 OAuth 2.1 中,已经加入设备授权码模式,那么本篇就来实现一下设备授权码登录。
设备授权码模式官网交互图如下。
时序图如下。
1. 代码流程介绍
实现设备授权码,涉及到自定义 OAuth2 客户端身份验证,我们先来看一下官方文档介绍。
上面的认证配置中,主要要求我们要提供 AuthenticationConverter、AuthenticationProvider 这两个对象,文档对两个对象的作用也进行了说明。
authenticationConverter():添加 AuthenticationConverter (预处理器),尝试从 HttpServletRequest 提取客户端凭据,用以构建 OAuth2ClientAuthenticationToken 实例。
authenticationProvider():添加 AuthenticationProvider (主处理器),用于验证 OAuth2ClientAuthenticationToken。
对官方 demo(链接地址)实现设备授权码的代码流程,梳理了一下,如下所示。
上面提到的 OAuth2ClientAuthenticationFilter、OAuth2DeviceAuthorizationEndpointFilter 两个过滤器,属于过滤链中的两个过滤器。
OAuth2ClientAuthenticationFilter:多种客户端请求类型的拦截器,下面的请求都会被拦截处理。
[Ant [pattern='/oauth2/token', POST], Ant [pattern='/oauth2/introspect', POST], Ant [pattern='/oauth2/revoke', POST], Ant [pattern='/oauth2/device_authorization', POST]]
OAuth2DeviceAuthorizationEndpointFilter:针对设备授权码类型的拦截器,只拦截处理 [pattern='/oauth2/device_authorization', POST] 这种请求。
2. 认证服务器改造
实现设备授权码模式,需对认证服务器进行改造, 为避免 spring-oauth-server 工程经过较多改造后,影响之前篇章的对照,我们将 spring-oauth-server 工程拷贝一份取名为 spring-oauth-device-server,作为新的认证服务器(端口不变),在此基础上进行修改,本篇大部分代码直接从官方 demo 拷贝。
pom.xml 添加依赖如下。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.oauth</groupId>
<artifactId>spring-oauth-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-oauth-device-server</artifactId>
<name>设备授权码认证服务器</name>
<dependencies>
<!--Spring Authorization Server-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<!--添加spring security cas支持-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-cas</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--mysql-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- mybatis-plus 3.5.3及以上版本 才支持 spring boot 3-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- spring-boot-starter-thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- webjars-locator-core -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>
<!-- bootstrap:5.2.3 -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>5.2.3</version>
</dependency>
<!-- jquery:3.6.4 -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.6.4</version>
</dependency>
</dependencies>
</project>
application.yml 配置如下
server:
ip: spring-oauth-server
port: 9000
logging:
level:
org.springframework.security: trace
spring:
application:
name: spring-oauth-server
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth-server?serverTimezone=UTC&userUnicode=true&characterEncoding=utf-8
username: root
password: root
启动类如下。
@SpringBootApplication
public class SpringOauthDeviceServerApplication {
public static void main(String[] args) {
SpringApplication.run(SpringOauthDeviceServerApplication.class,args);
}
}
对 SecurityConfig 配置类进行拆分,像官方demo一样,将 Spring Security 的相关配置,放在 DefaultSecurityConfig 类中,将 Spring Authorization Server 相关的配置放在 AuthorizationServerConfig 类中。
DefaultSecurityConfig 代码如下。
@Configuration
@EnableWebSecurity
public class DefaultSecurityConfig {
/**
*Spring Security 过滤链配置(此处是纯Spring Security相关配置)
*/
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
// http
// //设置所有请求都需要认证,未认证的请求都被重定向到login页面进行登录
// .authorizeHttpRequests((authorize) -> authorize
// .anyRequest().authenticated()
// )
// // 由Spring Security过滤链中UsernamePasswordAuthenticationFilter过滤器拦截处理“login”页面提交的登录信息。
// .formLogin(Customizer.withDefaults());
http
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin ->
formLogin
.loginPage("/login")
);
return http.build();
}
/**
*设置用户信息,校验用户名、密码
* 这里或许有人会有疑问,不是说OAuth 2.1已经移除了密码模式了码?怎么这里还有用户名、密码登录?
* 例如:某平台app支持微信登录,用户想使用微信账号登录登录该平台app,则用户需先登录微信app,
* 此处代码的操作就类似于某平台app跳到微信登录界面让用户先登录微信,然后微信校验用户提交的用户名、密码,
* 登录了微信才对某平台app进行授权,对于微信平台来说,某平台的app就是OAuth 2.1中的客户端。
* 其实,这一步是Spring Security的操作,纯碎是认证平台的操作,是脱离客户端(第三方平台)的。
*/
// @Bean
// public UserDetailsService userDetailsService() {
// UserDetails userDetails = User.withDefaultPasswordEncoder()
// .username("user")
// .password("password")
// .roles("USER")
// .build();
// //基于内存的用户数据校验
// return new InMemoryUserDetailsManager(userDetails);
// }
}
AuthorizationServerConfig 代码如下:
@Configuration
public class AuthorizationServerConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
/**
* Spring Authorization Server 相关配置
* 此处方法与下面defaultSecurityFilterChain都是SecurityFilterChain配置,配置的内容有点区别,
* 因为Spring Authorization Server是建立在Spring Security 基础上的,defaultSecurityFilterChain方法主要
* 配置Spring Security相关的东西,而此处authorizationServerSecurityFilterChain方法主要配置OAuth 2.1和OpenID Connect 1.0相关的东西
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
//AuthenticationConverter(预处理器),尝试从HttpServletRequest提取客户端凭据,用以构建OAuth2ClientAuthenticationToken实例。
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
//AuthenticationProvider(主处理器),用于验证OAuth2ClientAuthenticationToken。
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
//设置用户码校验地址
deviceAuthorizationEndpoint.verificationUri("/activate")
)
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
//设置授权页地址
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
//设置AuthenticationConverter(预处理器)和AuthenticationProvider(主处理器)
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
)
.authorizationEndpoint(authorizationEndpoint ->
authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
//开启OpenID Connect 1.0(其中oidc为OpenID Connect的缩写)。
.oidc(Customizer.withDefaults());
//设置登录地址,需要进行认证的请求被重定向到该地址
http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 客户端信息
* 对应表:oauth2_registered_client
*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
/**
* 授权信息
* 对应表:oauth2_authorization
*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
/**
* 授权确认
*对应表:oauth2_authorization_consent
*/
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
*配置 JWK,为JWT(id_token)提供加密密钥,用于加密/解密或签名/验签
* JWK详细见:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
*/
@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);
}
/**
*生成RSA密钥对,给上面jwkSource() 方法的提供密钥对
*/
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;
}
/**
* 配置jwt解析器
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
*配置认证服务器请求地址
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
//什么都不配置,则使用默认地址
return AuthorizationServerSettings.builder().build();
}
}
AuthorizationServerConfig 类中,我们增加创建了 DeviceClientAuthenticationConverter、DeviceClientAuthenticationProvider 这两个对象,并对他们设置到配置当中。
DeviceClientAuthenticationConverter、DeviceClientAuthenticationProvider、DeviceClientAuthenticationToken 均从官方 demo 直接拷贝过来,放到我们的工程包 org.oauth.server.authentication 路径下。三个类的代码如下。
/*
* 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.oauth.server.authentication;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
/**
* @author Joe Grandja
* @author Steve Riesenberg
* @since 1.1
*/
public final class DeviceClientAuthenticationConverter implements AuthenticationConverter {
private final RequestMatcher deviceAuthorizationRequestMatcher;
private final RequestMatcher deviceAccessTokenRequestMatcher;
public DeviceClientAuthenticationConverter(String deviceAuthorizationEndpointUri) {
RequestMatcher clientIdParameterMatcher = request ->
request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null;
this.deviceAuthorizationRequestMatcher = new AndRequestMatcher(
new AntPathRequestMatcher(
deviceAuthorizationEndpointUri, HttpMethod.POST.name()),
clientIdParameterMatcher);
this.deviceAccessTokenRequestMatcher = request ->
AuthorizationGrantType.DEVICE_CODE.getValue().equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE)) &&
request.getParameter(OAuth2ParameterNames.DEVICE_CODE) != null &&
request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null;
}
@Nullable
@Override
public Authentication convert(HttpServletRequest request) {
if (!this.deviceAuthorizationRequestMatcher.matches(request) &&
!this.deviceAccessTokenRequestMatcher.matches(request)) {
return null;
}
// client_id (REQUIRED)
String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID);
if (!StringUtils.hasText(clientId) ||
request.getParameterValues(OAuth2ParameterNames.CLIENT_ID).length != 1) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
}
return new DeviceClientAuthenticationToken(clientId, ClientAuthenticationMethod.NONE, null, null);
}
}
/*
* 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.oauth.server.authentication;
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.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
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.web.OAuth2ClientAuthenticationFilter;
import org.springframework.util.Assert;
/**
* @author Joe Grandja
* @author Steve Riesenberg
* @since 1.1
* @see DeviceClientAuthenticationToken
* @see DeviceClientAuthenticationConverter
* @see OAuth2ClientAuthenticationFilter
*/
public final class DeviceClientAuthenticationProvider implements AuthenticationProvider {
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
private final Log logger = LogFactory.getLog(getClass());
private final RegisteredClientRepository registeredClientRepository;
public DeviceClientAuthenticationProvider(RegisteredClientRepository registeredClientRepository) {
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
this.registeredClientRepository = registeredClientRepository;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
DeviceClientAuthenticationToken deviceClientAuthentication =
(DeviceClientAuthenticationToken) authentication;
if (!ClientAuthenticationMethod.NONE.equals(deviceClientAuthentication.getClientAuthenticationMethod())) {
return null;
}
String clientId = deviceClientAuthentication.getPrincipal().toString();
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
if (registeredClient == null) {
throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Retrieved registered client");
}
if (!registeredClient.getClientAuthenticationMethods().contains(
deviceClientAuthentication.getClientAuthenticationMethod())) {
throwInvalidClient("authentication_method");
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Validated device client authentication parameters");
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("Authenticated device client");
}
return new DeviceClientAuthenticationToken(registeredClient,
deviceClientAuthentication.getClientAuthenticationMethod(), null);
}
@Override
public boolean supports(Class<?> authentication) {
return DeviceClientAuthenticationToken.class.isAssignableFrom(authentication);
}
private static void throwInvalidClient(String parameterName) {
OAuth2Error error = new OAuth2Error(
OAuth2ErrorCodes.INVALID_CLIENT,
"Device client authentication failed: " + parameterName,
ERROR_URI
);
throw new OAuth2AuthenticationException(error);
}
}
/*
* 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.oauth.server.authentication;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Transient;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import java.util.Map;
/**
* @author Joe Grandja
* @author Steve Riesenberg
* @since 1.1
*/
@Transient
public class DeviceClientAuthenticationToken extends OAuth2ClientAuthenticationToken {
public DeviceClientAuthenticationToken(String clientId, ClientAuthenticationMethod clientAuthenticationMethod,
@Nullable Object credentials, @Nullable Map<String, Object> additionalParameters) {
super(clientId, clientAuthenticationMethod, credentials, additionalParameters);
}
public DeviceClientAuthenticationToken(RegisteredClient registeredClient, ClientAuthenticationMethod clientAuthenticationMethod,
@Nullable Object credentials) {
super(registeredClient, clientAuthenticationMethod, credentials);
}
}
将官方 demo 中 web 目录下的 AuthorizationConsentController、DefaultErrorController、DeviceController、LoginController 四个类也拷贝过来,放到我们工程的 org.oauth.server.controler 目录下。
AuthorizationConsentController 代码如下:
/*
* 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.oauth.server.controler;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.security.Principal;
import java.util.*;
/**
* @author Daniel Garnier-Moiroux
*/
@Controller
public class AuthorizationConsentController {
private final RegisteredClientRepository registeredClientRepository;
private final OAuth2AuthorizationConsentService authorizationConsentService;
public AuthorizationConsentController(RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationConsentService authorizationConsentService) {
this.registeredClientRepository = registeredClientRepository;
this.authorizationConsentService = authorizationConsentService;
}
@GetMapping(value = "/oauth2/consent")
public String consent(Principal principal, Model model,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state,
@RequestParam(name = OAuth2ParameterNames.USER_CODE, required = false) String userCode) {
// Remove scopes that were already approved
Set<String> scopesToApprove = new HashSet<>();
Set<String> previouslyApprovedScopes = new HashSet<>();
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
OAuth2AuthorizationConsent currentAuthorizationConsent =
this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
Set<String> authorizedScopes;
if (currentAuthorizationConsent != null) {
authorizedScopes = currentAuthorizationConsent.getScopes();
} else {
authorizedScopes = Collections.emptySet();
}
for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {
if (OidcScopes.OPENID.equals(requestedScope)) {
continue;
}
if (authorizedScopes.contains(requestedScope)) {
previouslyApprovedScopes.add(requestedScope);
} else {
scopesToApprove.add(requestedScope);
}
}
model.addAttribute("clientId", clientId);
model.addAttribute("state", state);
model.addAttribute("scopes", withDescription(scopesToApprove));
model.addAttribute("previouslyApprovedScopes", withDescription(previouslyApprovedScopes));
model.addAttribute("principalName", principal.getName());
model.addAttribute("userCode", userCode);
if (StringUtils.hasText(userCode)) {
model.addAttribute("requestURI", "/oauth2/device_verification");
} else {
model.addAttribute("requestURI", "/oauth2/authorize");
}
return "consent";
}
private static Set<ScopeWithDescription> withDescription(Set<String> scopes) {
Set<ScopeWithDescription> scopeWithDescriptions = new HashSet<>();
for (String scope : scopes) {
scopeWithDescriptions.add(new ScopeWithDescription(scope));
}
return scopeWithDescriptions;
}
public static class ScopeWithDescription {
private static final String DEFAULT_DESCRIPTION = "UNKNOWN SCOPE - We cannot provide information about this permission, use caution when granting this.";
private static final Map<String, String> scopeDescriptions = new HashMap<>();
static {
scopeDescriptions.put(
OidcScopes.PROFILE,
"This application will be able to read your profile information."
);
scopeDescriptions.put(
"message.read",
"This application will be able to read your message."
);
scopeDescriptions.put(
"message.write",
"This application will be able to add new messages. It will also be able to edit and delete existing messages."
);
scopeDescriptions.put(
"other.scope",
"This is another scope example of a scope description."
);
}
public final String scope;
public final String description;
ScopeWithDescription(String scope) {
this.scope = scope;
this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION);
}
}
}
DefaultErrorController 代码如下。
/*
* 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.oauth.server.controler;
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.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Controller
public class DefaultErrorController implements ErrorController {
@RequestMapping("/error")
public String handleError(Model model, HttpServletRequest request) {
String errorMessage = getErrorMessage(request);
if (errorMessage.startsWith("[access_denied]")) {
model.addAttribute("errorTitle", "Access Denied");
model.addAttribute("errorMessage", "You have denied access.");
} else {
model.addAttribute("errorTitle", "Error");
model.addAttribute("errorMessage", errorMessage);
}
return "error";
}
private String getErrorMessage(HttpServletRequest request) {
return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
}
}
DeviceController 代码如下。
/*
* 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.oauth.server.controler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@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 "device-activate";
}
@GetMapping("/activated")
public String activated() {
return "device-activated";
}
@GetMapping(value = "/", params = "success")
public String success() {
return "device-activated";
}
}
LoginController 代码如下。
/*
* 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.oauth.server.controler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}
最后将官方 demo 中的前端页面和静态资源也拷贝过来,放到 resources 目录下。
拷贝上面的几个 Controller 文件、前端页面、静态资源是为了覆盖框架中自带的认证页和授权页面,在前面的几章中,都是直接使用框架中自带的认证页面和授权页面,页面跳转或加载时非常卡(可能是框架中的一些前端资源地址无法打开,然后一直加载的原因,例如 bootstrap.min.css 样式地址:https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css)。
spring-oauth-device-server 代码结构如下。
3. 认证服务器测试
先在客户端信息表 oauth2_registered_client 中增加一条如下客户端记录。
INSERT INTO `oauth-server`.`oauth2_registered_client` (`id`, `client_id`, `client_id_issued_at`, `client_secret`, `client_secret_expires_at`, `client_name`, `client_authentication_methods`, `authorization_grant_types`, `redirect_uris`, `post_logout_redirect_uris`, `scopes`, `client_settings`, `token_settings`) VALUES ('d84e9e7c-abb1-46f7-bb0f-4511af362ca5', 'device-client-id', '2023-07-15 16:34:15', '', NULL, '智能设备', 'none', 'refresh_token,urn:ietf:params:oauth:grant-type:device_code', '', '', 'message.read,message.write', '{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.client.require-proof-key\":false,\"settings.client.require-authorization-consent\":false}', '\r\n{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.token.reuse-refresh-tokens\":true,\"settings.token.id-token-signature-algorithm\":[\"org.springframework.security.oauth2.jose.jws.SignatureAlgorithm\",\"RS256\"],\"settings.token.access-token-time-to-live\":[\"java.time.Duration\",1800.000000000],\"settings.token.access-token-format\":{\"@class\":\"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat\",\"value\":\"self-contained\"},\"settings.token.refresh-token-time-to-live\":[\"java.time.Duration\",3600.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",1800.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",1800.000000000]}');
客户端id:device-client-id,权限范围:message.read,message.write
启动 spring-oauth-device-serve 服务,postman 输入 client_id、scope 参数,向 http://spring-oauth-server:9000/oauth2/device_authorization 地址获取用户码、设备码、授权地址,结果如下。
注意:此时是客户端的操作,接下来是用户的操作。
将 http://spring-oauth-server:9000/activate 地址放到浏览器打开,提示我们要登录。
输入用户名:user,密码:123456,跳转到用户码输入界面。
输入用户码:GWVL-KWZM,点击提交按钮,则跳转到授权确认页面。
勾选授权,点击提交授权,则显示操作成功。
此时用户的操作已经完成,接下来是客户端的操作,上面客户端拿到认证服务器返回的用户码、设备码、授权地址后,通过发送消息给用户或者显示连接二维码让用户扫码,然后就一直轮询向认证服务器发起设备码换取令牌的请求了。postman 输入client_id、grant_type、device_code 等参数,向 http://spring-oauth-server:9000/oauth2/token 地址发起设备码换取令牌(token)的请求,结果如下。
至此,设备授权码模式获取令牌已完成。
4. 注意点提醒
1:我们在 AuthorizationServerConfig 类中设置的 DeviceClientAuthenticationConverter,也会被其他授权模式调用到,例如授权码模式,使用 code 去换取 token 的时候就会调用到,要注意兼容性。
2:在测试设备授权码的过程中,我们的客户端认证方法使用 ClientAuthenticationMethod.NONE(公共客户端),数据库表 oauth2_registered_client 的 client_authentication_methods 字段值只设置为 none 值,postman 请求获取 token 时,Authorization 未设置参数,虽然测试结果中返回了 refresh_token,但此时无法使用 refresh_token 获取 access_token,因为使用 refresh_token 获取 access_token 需要支持 ClientAuthenticationMethod.CLIENT_SECRET_BASIC 方法,如果想要设备授权码模式返回的 refresh_token 能正常使用,则需要在客户端注册时,增加 secret,同时数据库表 oauth2_registered_client 的 client_authentication_methods 字段值设置为 none,client_secret_basic值。
3:如果你是在本机进行操作,使用 http://spring-oauth-server:9000/oauth2/token 地址获取 access_token 时,不要将域名 spring-oauth-server 改成 localhost 或 127.0.0.1,否则你获取到的 access_token 中的 iss(颁发者)为 http://localhost:9000 或 http://127.0.0.1:9000,当你带着 iss 为 http://localhost:9000 或 http://127.0.0.1:9000 的 access_token 去访问资源服务器的时候,将会报 Bearer error="invalid_token", error_descriptinotallow="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" 的错误。如果非得要在本机进行 localhost 或 127.0.0.1 访问,可以在配置类中设置如下。
5. 总结
本篇对设备授权码模式进行了相关介绍,对认证服务器进行了改造,实现了设备授权码模式,也优化了认证页面、授权页面的响应速度,最后列出了使用设备授权码模式的过程中需要注意的一些地方。
项目代码地址:链接地址