在前面的章节中,我们介绍过客户端使用授权码方式获取令牌(token),但是往往有些客户端所在的环境是没有浏览器的。例如:智能电视、媒体控制台、数字相框、打印机等,像这类的设备,要在设备上面将用户操作引导至认证平台界面去输入账号、密码,然后确认授权,这显示不现实。解决这类设备的登录场景,最常见的就是手机扫码登录,或将认证地址发送到手机上,让用户在手机浏览器上进行认证授权。在 OAuth 2.1 中,已经加入设备授权码模式,那么本篇就来实现一下设备授权码登录。

设备授权码模式官网交互图如下。

Spring Authorization Server (五)设备授权码登录_设备授权码登录

时序图如下。

Spring Authorization Server (五)设备授权码登录_设备授权码登录_02

1. 代码流程介绍

实现设备授权码,涉及到自定义 OAuth2 客户端身份验证,我们先来看一下官方文档介绍。

Spring Authorization Server (五)设备授权码登录_设备授权码登录_03

上面的认证配置中,主要要求我们要提供 AuthenticationConverter、AuthenticationProvider 这两个对象,文档对两个对象的作用也进行了说明。

authenticationConverter():添加 AuthenticationConverter (预处理器),尝试从 HttpServletRequest 提取客户端凭据,用以构建 OAuth2ClientAuthenticationToken 实例。

authenticationProvider():添加 AuthenticationProvider (主处理器),用于验证 OAuth2ClientAuthenticationToken。

对官方 demo(链接地址)实现设备授权码的代码流程,梳理了一下,如下所示。

Spring Authorization Server (五)设备授权码登录_Authorization Server_04

上面提到的 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 目录下。

Spring Authorization Server (五)设备授权码登录_Spring Boot3_05

拷贝上面的几个 Controller 文件、前端页面、静态资源是为了覆盖框架中自带的认证页和授权页面,在前面的几章中,都是直接使用框架中自带的认证页面和授权页面,页面跳转或加载时非常卡(可能是框架中的一些前端资源地址无法打开,然后一直加载的原因,例如 bootstrap.min.css 样式地址:https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css)。

spring-oauth-device-server 代码结构如下。

Spring Authorization Server (五)设备授权码登录_Spring Security_06

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 地址获取用户码、设备码、授权地址,结果如下。

Spring Authorization Server (五)设备授权码登录_Authorization Server_07

注意:此时是客户端的操作,接下来是用户的操作。

将 http://spring-oauth-server:9000/activate 地址放到浏览器打开,提示我们要登录。

Spring Authorization Server (五)设备授权码登录_设备授权码登录_08

输入用户名:user,密码:123456,跳转到用户码输入界面。

Spring Authorization Server (五)设备授权码登录_Spring Security_09

输入用户码:GWVL-KWZM,点击提交按钮,则跳转到授权确认页面。

Spring Authorization Server (五)设备授权码登录_设备授权码登录_10

勾选授权,点击提交授权,则显示操作成功。

Spring Authorization Server (五)设备授权码登录_Spring Security_11

此时用户的操作已经完成,接下来是客户端的操作,上面客户端拿到认证服务器返回的用户码、设备码、授权地址后,通过发送消息给用户或者显示连接二维码让用户扫码,然后就一直轮询向认证服务器发起设备码换取令牌的请求了。postman 输入client_id、grant_type、device_code 等参数,向 http://spring-oauth-server:9000/oauth2/token 地址发起设备码换取令牌(token)的请求,结果如下。

Spring Authorization Server (五)设备授权码登录_Authorization Server_12

至此,设备授权码模式获取令牌已完成。

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 访问,可以在配置类中设置如下。

Spring Authorization Server (五)设备授权码登录_Spring Boot3_13

5. 总结

本篇对设备授权码模式进行了相关介绍,对认证服务器进行了改造,实现了设备授权码模式,也优化了认证页面、授权页面的响应速度,最后列出了使用设备授权码模式的过程中需要注意的一些地方。


项目代码地址:链接地址