Spring Authorization Server 1.1 扩展 OAuth2 密码模式与 Spring Cloud Gateway 整合实战

目录

    • 前言
    • 无图无真相
    • 创建数据库
    • 授权服务器
      • maven 依赖
      • application.yml
      • 授权服务器配置
        • AuthorizationServierConfig
        • DefaultSecutiryConfig
      • 密码模式扩展
        • PasswordAuthenticationToken
        • PasswordAuthenticationConverter
        • PasswordAuthenticationProvider
      • JWT 自定义字段
      • 自定义认证响应
        • 认证成功响应
        • 认证失败响应
        • 配置自定义处理器
      • 密码模式测试
        • 单元测试
        • Postman 测试
    • 资源服务器
      • maven 依赖
      • application.yml
      • 资源服务器配置
    • 认证流程测试
      • 登录认证授权
      • 获取用户信息
    • 结语
    • 源码
    • 参考文档

前言

Spring Security OAuth2 的最终版本是2.5.2,并于2022年6月5日正式宣布停止维护。Spring 官方为此推出了新的替代产品,即 Spring Authorization Server。然而,出于安全考虑,Spring Authorization Server 不再支持密码模式,因为密码模式要求客户端直接处理用户的密码。但对于受信任的第一方系统(自有APP和管理系统等),许多情况下需要使用密码模式。在这种情况下,需要在 Spring Authorization Server 的基础上扩展密码模式的支持。本文基于开源微服务商城项目 youlai-mall、Spring Boot 3 和 Spring Authorization Server 1.1 版本,演示了如何扩展密码模式,以及如何将其应用于 Spring Cloud 微服务实战。

无图无真相

通过 Spring Cloud Gateway 访问认证中心认证成功获取到访问令牌。完整源码:youlai-mall

创建数据库

Spring Authorization Server 官方提供的授权服务器示例 demo-authorizationserver 初始化数据库所使用的3个SQL脚本路径如下:

根据路径找到3张表的SQL脚本

  • 令牌发放记录表: oauth2-authorization-schema.sql
  • 授权记录表: oauth2-authorization-consent-schema.sql
  • 客户端信息表: oauth2-registered-client-schema.sql

整合后的完整数据库 SQL 脚本如下:

-- ----------------------------
-- 1. 创建数据库
-- ----------------------------
CREATE DATABASE IF NOT EXISTS oauth2_server DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;-- ----------------------------
-- 2. 创建表
-- ----------------------------
use oauth2_server;SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 2.1 oauth2_authorization 令牌发放记录表
-- ----------------------------
CREATE TABLE oauth2_authorization (id varchar(100) NOT NULL,registered_client_id varchar(100) NOT NULL,principal_name varchar(200) NOT NULL,authorization_grant_type varchar(100) NOT NULL,authorized_scopes varchar(1000) DEFAULT NULL,attributes blob DEFAULT NULL,state varchar(500) DEFAULT NULL,authorization_code_value blob DEFAULT NULL,authorization_code_issued_at timestamp DEFAULT NULL,authorization_code_expires_at timestamp DEFAULT NULL,authorization_code_metadata blob DEFAULT NULL,access_token_value blob DEFAULT NULL,access_token_issued_at timestamp DEFAULT NULL,access_token_expires_at timestamp DEFAULT NULL,access_token_metadata blob DEFAULT NULL,access_token_type varchar(100) DEFAULT NULL,access_token_scopes varchar(1000) DEFAULT NULL,oidc_id_token_value blob DEFAULT NULL,oidc_id_token_issued_at timestamp DEFAULT NULL,oidc_id_token_expires_at timestamp DEFAULT NULL,oidc_id_token_metadata blob DEFAULT NULL,refresh_token_value blob DEFAULT NULL,refresh_token_issued_at timestamp DEFAULT NULL,refresh_token_expires_at timestamp DEFAULT NULL,refresh_token_metadata blob DEFAULT NULL,user_code_value blob DEFAULT NULL,user_code_issued_at timestamp DEFAULT NULL,user_code_expires_at timestamp DEFAULT NULL,user_code_metadata blob DEFAULT NULL,device_code_value blob DEFAULT NULL,device_code_issued_at timestamp DEFAULT NULL,device_code_expires_at timestamp DEFAULT NULL,device_code_metadata blob DEFAULT NULL,PRIMARY KEY (id)
);-- ----------------------------
-- 2.2 oauth2_authorization_consent 授权记录表
-- ----------------------------
CREATE TABLE oauth2_authorization_consent (registered_client_id varchar(100) NOT NULL,principal_name varchar(200) NOT NULL,authorities varchar(1000) NOT NULL,PRIMARY KEY (registered_client_id, principal_name)
);-- ----------------------------
-- 2.3 oauth2-registered-client OAuth2 客户端信息表
-- ----------------------------
CREATE TABLE oauth2_registered_client (id varchar(100) NOT NULL,client_id varchar(100) NOT NULL,client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,client_secret varchar(200) DEFAULT NULL,client_secret_expires_at timestamp DEFAULT NULL,client_name varchar(200) NOT NULL,client_authentication_methods varchar(1000) NOT NULL,authorization_grant_types varchar(1000) NOT NULL,redirect_uris varchar(1000) DEFAULT NULL,post_logout_redirect_uris varchar(1000) DEFAULT NULL,scopes varchar(1000) NOT NULL,client_settings varchar(2000) NOT NULL,token_settings varchar(2000) NOT NULL,PRIMARY KEY (id)
);

授权服务器

youlai-auth 模块作为认证授权服务器

maven 依赖

在 youlai-auth 模块的 pom.xml 添加授权服务器依赖

<!-- Spring Authorization Server 授权服务器依赖 -->
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-authorization-server</artifactId><version>1.1.1</version>
</dependency>

application.yml

认证中心配置 oauth2_server 数据库连接信息

spring:datasource:type: com.alibaba.druid.pool.DruidDataSourcedriver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/oauth2_server?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=trueusername: rootpassword: 123456

授权服务器配置

参考 Spring Authorization Server 官方示例 demo-authorizationserver

AuthorizationServierConfig

参考: Spring Authorization Server 官方示例 demo-authorizationserver 下的 AuthorizationServerConfig.java 进行授权服务器配置

package com.youlai.auth.config;/*** 授权服务器配置** @author haoxr* @since 3.0.0*/
@Configuration
@RequiredArgsConstructor
@Slf4j
public class AuthorizationServerConfig {private final OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;/*** 授权服务器端点配置*/@Bean@Order(Ordered.HIGHEST_PRECEDENCE)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,AuthenticationManager authenticationManager,OAuth2AuthorizationService authorizationService,OAuth2TokenGenerator<?> tokenGenerator) throws Exception {OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();authorizationServerConfigurer.tokenEndpoint(tokenEndpoint ->tokenEndpoint.accessTokenRequestConverters(authenticationConverters ->// <1>authenticationConverters.addAll(// 自定义授权模式转换器(Converter)List.of(new PasswordAuthenticationConverter()))).authenticationProviders(authenticationProviders ->// <2>authenticationProviders.addAll(// 自定义授权模式提供者(Provider)List.of(new PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator)))).accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应.errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应);RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();http.securityMatcher(endpointsMatcher).authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated()).csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)).apply(authorizationServerConfigurer);return http.build();}@Bean // <5>public JWKSource<SecurityContext> jwkSource() {KeyPair keyPair = generateRsaKey();RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();// @formatter:offRSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();// @formatter:onJWKSet jwkSet = new JWKSet(rsaKey);return new ImmutableJWKSet<>(jwkSet);}private static KeyPair generateRsaKey() { // <6>KeyPair keyPair;try {KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");keyPairGenerator.initialize(2048);keyPair = keyPairGenerator.generateKeyPair();} catch (Exception ex) {throw new IllegalStateException(ex);}return keyPair;}@Beanpublic JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);}@Beanpublic AuthorizationServerSettings authorizationServerSettings() {return AuthorizationServerSettings.builder().build();}@Beanpublic PasswordEncoder passwordEncoder() {return PasswordEncoderFactories.createDelegatingPasswordEncoder();}@Beanpublic RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);// 初始化 OAuth2 客户端initMallAppClient(registeredClientRepository);initMallAdminClient(registeredClientRepository);return registeredClientRepository;}@Beanpublic OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,RegisteredClientRepository registeredClientRepository) {JdbcOAuth2AuthorizationService service = new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper = new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(registeredClientRepository);rowMapper.setLobHandler(new DefaultLobHandler());ObjectMapper objectMapper = new ObjectMapper();ClassLoader classLoader = JdbcOAuth2AuthorizationService.class.getClassLoader();List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);objectMapper.registerModules(securityModules);objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());// 使用刷新模式,需要从 oauth2_authorization 表反序列化attributes字段得到用户信息(SysUserDetails)objectMapper.addMixIn(SysUserDetails.class, SysUserMixin.class);objectMapper.addMixIn(Long.class, Object.class);rowMapper.setObjectMapper(objectMapper);service.setAuthorizationRowMapper(rowMapper);return service;}@Beanpublic OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,RegisteredClientRepository registeredClientRepository) {// Will be used by the ConsentControllerreturn new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);}@BeanOAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));jwtGenerator.setJwtCustomizer(jwtCustomizer);OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator);}@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}/*** 初始化创建商城管理客户端** @param registeredClientRepository*/private void initMallAdminClient(JdbcRegisteredClientRepository registeredClientRepository) {String clientId = "mall-admin";String clientSecret = "123456";String clientName = "商城管理客户端";/*如果使用明文,客户端认证时会自动升级加密方式,换句话说直接修改客户端密码,所以直接使用 bcrypt 加密避免不必要的麻烦官方ISSUE: https://github.com/spring-projects/spring-authorization-server/issues/1099*/String encodeSecret = passwordEncoder().encode(clientSecret);RegisteredClient registeredMallAdminClient = registeredClientRepository.findByClientId(clientId);String id = registeredMallAdminClient != null ? registeredMallAdminClient.getId() : UUID.randomUUID().toString();RegisteredClient mallAppClient = RegisteredClient.withId(id).clientId(clientId).clientSecret(encodeSecret).clientName(clientName).clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC).authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN).authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).authorizationGrantType(AuthorizationGrantType.PASSWORD) // 密码模式.authorizationGrantType(CaptchaAuthenticationToken.CAPTCHA) // 验证码模式.redirectUri("http://127.0.0.1:8080/authorized").postLogoutRedirectUri("http://127.0.0.1:8080/logged-out").scope(OidcScopes.OPENID).scope(OidcScopes.PROFILE).tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(1)).build()).clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();registeredClientRepository.save(mallAppClient);}/*** 初始化创建商城APP客户端** @param registeredClientRepository*/private void initMallAppClient(JdbcRegisteredClientRepository registeredClientRepository) {String clientId = "mall-app";String clientSecret = "123456";String clientName = "商城APP客户端";// 如果使用明文,在客户端认证的时候会自动升级加密方式,直接使用 bcrypt 加密避免不必要的麻烦String encodeSecret = passwordEncoder().encode(clientSecret);RegisteredClient registeredMallAppClient = registeredClientRepository.findByClientId(clientId);String id = registeredMallAppClient != null ? registeredMallAppClient.getId() : UUID.randomUUID().toString();RegisteredClient mallAppClient = RegisteredClient.withId(id).clientId(clientId).clientSecret(encodeSecret).clientName(clientName).clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC).authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN).authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).authorizationGrantType(WxMiniAppAuthenticationToken.WECHAT_MINI_APP) // 微信小程序模式.authorizationGrantType(SmsCodeAuthenticationToken.SMS_CODE) // 短信验证码模式.redirectUri("http://127.0.0.1:8080/authorized").postLogoutRedirectUri("http://127.0.0.1:8080/logged-out").scope(OidcScopes.OPENID).scope(OidcScopes.PROFILE).tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(1)).build()).clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();registeredClientRepository.save(mallAppClient);}
}
DefaultSecutiryConfig
  • 参考 Spring Authorization Server 官方示例 demo-authorizationserver 下的 DefaultSecurityConfig.java 进行安全配置
package com.youlai.auth.config;/*** 授权服务器安全配置** @author haoxr* @since 3.0.0*/
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {/*** Spring Security 安全过滤器链配置*/@Bean@Order(0)SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests(requestMatcherRegistry ->{requestMatcherRegistry.anyRequest().authenticated();}).csrf(AbstractHttpConfigurer::disable).formLogin(Customizer.withDefaults());return http.build();}/*** Spring Security 自定义安全配置*/@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {return (web) ->// 不走过滤器链(场景:静态资源js、css、html)web.ignoring().requestMatchers("/webjars/**","/doc.html","/swagger-resources/**","/v3/api-docs/**","/swagger-ui/**");}
}

密码模式扩展

PasswordAuthenticationToken
package com.youlai.auth.authentication.password;/*** 密码授权模式身份验证令牌(包含用户名和密码等)** @author haoxr* @since 3.0.0*/
public class PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {public static final AuthorizationGrantType PASSWORD = new AuthorizationGrantType("password");/*** 令牌申请访问范围*/private final Set<String> scopes;/*** 密码模式身份验证令牌** @param clientPrincipal      客户端信息* @param scopes               令牌申请访问范围* @param additionalParameters 自定义额外参数(用户名和密码)*/public PasswordAuthenticationToken(Authentication clientPrincipal,Set<String> scopes,@Nullable Map<String, Object> additionalParameters) {super(PASSWORD, clientPrincipal, additionalParameters);this.scopes = Collections.unmodifiableSet(scopes != null ? new HashSet<>(scopes) : Collections.emptySet());}/*** 用户凭证(密码)*/@Overridepublic Object getCredentials() {return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD);}public Set<String> getScopes() {return scopes;}
}
PasswordAuthenticationConverter
package com.youlai.auth.authentication.password;/*** 密码模式参数解析器* <p>* 解析请求参数中的用户名和密码,并构建相应的身份验证(Authentication)对象** @author haoxr* @see org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter* @since 3.0.0*/
public class PasswordAuthenticationConverter implements AuthenticationConverter {@Overridepublic Authentication convert(HttpServletRequest request) {// 授权类型 (必需)String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) {return null;}// 客户端信息Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();// 参数提取验证MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);// 令牌申请访问范围验证 (可选)String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);if (StringUtils.hasText(scope) &&parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST,OAuth2ParameterNames.SCOPE,OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);}Set<String> requestedScopes = null;if (StringUtils.hasText(scope)) {requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));}// 用户名验证(必需)String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);if (StrUtil.isBlank(username)) {OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST,OAuth2ParameterNames.USERNAME,OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);}// 密码验证(必需)String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);if (StrUtil.isBlank(password)) {OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST,OAuth2ParameterNames.PASSWORD,OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);}// 附加参数(保存用户名/密码传递给 PasswordAuthenticationProvider 用于身份认证)Map<String, Object> additionalParameters = parameters.entrySet().stream().filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE) &&!e.getKey().equals(OAuth2ParameterNames.SCOPE)).collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));return new PasswordAuthenticationToken(clientPrincipal,requestedScopes,additionalParameters);}}
PasswordAuthenticationProvider
package com.youlai.auth.authentication.password;/*** 密码模式身份验证提供者* <p>* 处理基于用户名和密码的身份验证** @author haoxr* @since 3.0.0*/
@Slf4j
public class PasswordAuthenticationProvider implements AuthenticationProvider {private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";private final AuthenticationManager authenticationManager;private final OAuth2AuthorizationService authorizationService;private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;/*** Constructs an {@code OAuth2ResourceOwnerPasswordAuthenticationProviderNew} using the provided parameters.** @param authenticationManager the authentication manager* @param authorizationService  the authorization service* @param tokenGenerator        the token generator* @since 0.2.3*/public PasswordAuthenticationProvider(AuthenticationManager authenticationManager,OAuth2AuthorizationService authorizationService,OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {Assert.notNull(authorizationService, "authorizationService cannot be null");Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");this.authenticationManager = authenticationManager;this.authorizationService = authorizationService;this.tokenGenerator = tokenGenerator;}@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {PasswordAuthenticationToken resourceOwnerPasswordAuthentication = (PasswordAuthenticationToken) authentication;OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient(resourceOwnerPasswordAuthentication);RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();// 验证客户端是否支持授权类型(grant_type=password)if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.PASSWORD)) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);}// 生成用户名密码身份验证令牌Map<String, Object> additionalParameters = resourceOwnerPasswordAuthentication.getAdditionalParameters();String username = (String) additionalParameters.get(OAuth2ParameterNames.USERNAME);String password = (String) additionalParameters.get(OAuth2ParameterNames.PASSWORD);UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);// 用户名密码身份验证,成功后返回带有权限的认证信息Authentication usernamePasswordAuthentication;try {usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);} catch (Exception e) {// 需要将其他类型的异常转换为 OAuth2AuthenticationException 才能被自定义异常捕获处理,逻辑源码 OAuth2TokenEndpointFilter#doFilterInternalthrow new OAuth2AuthenticationException(e.getCause() != null ? e.getCause().getMessage() : e.getMessage());}// 验证申请访问范围(Scope)Set<String> authorizedScopes = registeredClient.getScopes();Set<String> requestedScopes = resourceOwnerPasswordAuthentication.getScopes();if (!CollectionUtils.isEmpty(requestedScopes)) {Set<String> unauthorizedScopes = requestedScopes.stream().filter(requestedScope -> !registeredClient.getScopes().contains(requestedScope)).collect(Collectors.toSet());if (!CollectionUtils.isEmpty(unauthorizedScopes)) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);}authorizedScopes = new LinkedHashSet<>(requestedScopes);}// 访问令牌(Access Token) 构造器DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder().registeredClient(registeredClient).principal(usernamePasswordAuthentication) // 身份验证成功的认证信息(用户名、权限等信息).authorizationServerContext(AuthorizationServerContextHolder.getContext()).authorizedScopes(authorizedScopes).authorizationGrantType(AuthorizationGrantType.PASSWORD) // 授权方式.authorizationGrant(resourceOwnerPasswordAuthentication) // 授权具体对象;// 生成访问令牌(Access Token)OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType((OAuth2TokenType.ACCESS_TOKEN)).build();OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);if (generatedAccessToken == null) {OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,"The token generator failed to generate the access token.", ERROR_URI);throw new OAuth2AuthenticationException(error);}OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());// 权限数据(perms)比较多通过反射移除,不随令牌一起持久化至数据库ReflectUtil.setFieldValue(usernamePasswordAuthentication.getPrincipal(), "perms", null);OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient).principalName(usernamePasswordAuthentication.getName()).authorizationGrantType(AuthorizationGrantType.PASSWORD).authorizedScopes(authorizedScopes).attribute(Principal.class.getName(), usernamePasswordAuthentication); // attribute 字段if (generatedAccessToken instanceof ClaimAccessor) {authorizationBuilder.token(accessToken, (metadata) ->metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));} else {authorizationBuilder.accessToken(accessToken);}// 生成刷新令牌(Refresh Token)OAuth2RefreshToken refreshToken = null;if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) &&// Do not issue refresh token to public client!clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) {OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,"The token generator failed to generate the refresh token.", ERROR_URI);throw new OAuth2AuthenticationException(error);}refreshToken = (OAuth2RefreshToken) generatedRefreshToken;authorizationBuilder.refreshToken(refreshToken);}OAuth2Authorization authorization = authorizationBuilder.build();// 持久化令牌发放记录到数据库this.authorizationService.save(authorization);additionalParameters = Collections.emptyMap();return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);}/*** 判断传入的 authentication 类型是否与当前认证提供者(AuthenticationProvider)相匹配--模板方法* <p>* ProviderManager#authenticate 遍历 providers 找到支持对应认证请求的 provider-迭代器模式** @param authentication* @return*/@Overridepublic boolean supports(Class<?> authentication) {return PasswordAuthenticationToken.class.isAssignableFrom(authentication);}}

JWT 自定义字段

参考官方 ISSUE :Adds how-to guide on adding authorities to access tokens

package com.youlai.auth.config;/*** JWT 自定义字段** @author haoxr* @since 3.0.0*/
@Configuration
@RequiredArgsConstructor
public class JwtTokenClaimsConfig {private final RedisTemplate redisTemplate;@Beanpublic OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {return context -> {if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) && context.getPrincipal() instanceof UsernamePasswordAuthenticationToken) {// Customize headers/claims for access_tokenOptional.ofNullable(context.getPrincipal().getPrincipal()).ifPresent(principal -> {JwtClaimsSet.Builder claims = context.getClaims();if (principal instanceof SysUserDetails userDetails) { // 系统用户添加自定义字段Long userId = userDetails.getUserId();claims.claim("user_id", userId);  // 添加系统用户ID// 角色集合存JWTvar authorities = AuthorityUtils.authorityListToSet(context.getPrincipal().getAuthorities()).stream().collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));claims.claim(SecurityConstants.AUTHORITIES_CLAIM_NAME_KEY, authorities);// 权限集合存Redis(数据多)Set<String> perms = userDetails.getPerms();redisTemplate.opsForValue().set(SecurityConstants.USER_PERMS_CACHE_PREFIX + userId, perms);} else if (principal instanceof MemberDetails userDetails) { // 商城会员添加自定义字段claims.claim("member_id", String.valueOf(userDetails.getId())); // 添加会员ID}});}};}}

自定义认证响应

🤔 如何自定义 OAuth2 认证成功或失败的响应数据结构符合当前系统统一的规范?

下图左侧部份是 OAuth2 原生返回(⬅️ ),大多数情况下,我们希望返回带有业务码的数据(➡️),以方便前端进行处理。

OAuth2 处理认证成功或失败源码坐标 OAuth2TokenEndpointFilter#doFilterInternal ,如下图:

根据源码阅读,发现只要重写✅ AuthenticationSuccessHandler 和❌ AuthenticationFailureHandler 的逻辑,就能够自定义认证成功和认证失败时的响应数据格式。

认证成功响应
package com.youlai.auth.handler;/*** 认证成功处理器** @author haoxr* @since 3.0.0*/
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {/*** MappingJackson2HttpMessageConverter 是 Spring 框架提供的一个 HTTP 消息转换器,用于将 HTTP 请求和响应的 JSON 数据与 Java 对象之间进行转换*/private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();private Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter();/*** 自定义认证成功响应数据结构** @param request the request which caused the successful authentication* @param response the response* @param authentication the <tt>Authentication</tt> object which was created during* the authentication process.* @throws IOException* @throws ServletException*/@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {OAuth2AccessTokenAuthenticationToken accessTokenAuthentication =(OAuth2AccessTokenAuthenticationToken) authentication;OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken();OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken();Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters();OAuth2AccessTokenResponse.Builder builder =OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue()).tokenType(accessToken.getTokenType());if (accessToken.getIssuedAt() != null && accessToken.getExpiresAt() != null) {builder.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));}if (refreshToken != null) {builder.refreshToken(refreshToken.getTokenValue());}if (!CollectionUtils.isEmpty(additionalParameters)) {builder.additionalParameters(additionalParameters);}OAuth2AccessTokenResponse accessTokenResponse = builder.build();Map<String, Object> tokenResponseParameters = this.accessTokenResponseParametersConverter.convert(accessTokenResponse);ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);this.accessTokenHttpResponseConverter.write(Result.success(tokenResponseParameters), null, httpResponse);}
}
认证失败响应
package com.youlai.auth.handler;/*** 认证失败处理器** @author haoxr* @since 2023/7/6*/
@Slf4j
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {/*** MappingJackson2HttpMessageConverter 是 Spring 框架提供的一个 HTTP 消息转换器,用于将 HTTP 请求和响应的 JSON 数据与 Java 对象之间进行转换*/private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();@Overridepublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {OAuth2Error error = ((OAuth2AuthenticationException) exception).getError();ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);Result result = Result.failed(error.getErrorCode());accessTokenHttpResponseConverter.write(result, null, httpResponse);}
}
配置自定义处理器

AuthorizationServierConfig

public SecurityFilterChain authorizationServerSecurityFilterChain() throws Exception {// ...authorizationServerConfigurer.tokenEndpoint(tokenEndpoint ->tokenEndpoint// ....accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应.errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应);}

密码模式测试

单元测试

启动 youlai-system 模块,需要从其获取系统用户信息(用户名、密码)进行认证

package com.youlai.auth.authentication;/*** OAuth2 密码模式单元测试*/
@SpringBootTest
@AutoConfigureMockMvc
@Slf4j
public class PasswordAuthenticationTests {@Autowiredprivate MockMvc mvc;/*** 测试密码模式登录*/@Testvoid testPasswordLogin() throws Exception {HttpHeaders headers = new HttpHeaders();// 客户端ID和密钥headers.setBasicAuth("mall-admin", "123456");this.mvc.perform(post("/oauth2/token").param(OAuth2ParameterNames.GRANT_TYPE, "password") // 密码模式.param(OAuth2ParameterNames.USERNAME, "admin") // 用户名.param(OAuth2ParameterNames.PASSWORD, "123456") // 密码.headers(headers)).andDo(print()).andExpect(status().isOk()).andExpect(jsonPath("$.data.access_token").isNotEmpty());}
}

单元测试通过,打印响应数据可以看到返回的 access_token 和 refresh_token

Postman 测试
  • 请求参数

  • 认证参数

    Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),

资源服务器

youlai-system 系统管理模块也作为资源服务器

maven 依赖

<!-- Spring Authorization Server 授权服务器依赖 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

application.yml

通过 Feign 请求 youlai-system 服务以获取系统用户认证信息(用户名和密码),在用户尚未登录的情况下,需要将此请求的路径配置到白名单中以避免拦截。

security:# 允许无需认证的路径列表whitelist-paths:# 获取系统用户的认证信息用于账号密码判读- /api/v1/users/{username}/authInfo

资源服务器配置

配置 ResourceServerConfig 位于资源服务器公共模块 common-security 中

package com.youlai.common.security.config;import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.json.JSONUtil;
import com.youlai.common.constant.SecurityConstants;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
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.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;import java.util.List;/*** 资源服务器配置** @author haoxr* @since 3.0.0*/
@ConfigurationProperties(prefix = "security")
@Configuration
@EnableWebSecurity
@Slf4j
public class ResourceServerConfig {/*** 白名单路径列表*/@Setterprivate List<String> ignoreUrls;@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {log.info("whitelist path:{}", JSONUtil.toJsonStr(ignoreUrls));http.authorizeHttpRequests(requestMatcherRegistry ->{if (CollectionUtil.isNotEmpty(ignoreUrls)) {requestMatcherRegistry.requestMatchers(Convert.toStrArray(ignoreUrls)).permitAll();}requestMatcherRegistry.anyRequest().authenticated();}).csrf(AbstractHttpConfigurer::disable);http.oauth2ResourceServer(resourceServerConfigurer ->resourceServerConfigurer.jwt(jwtConfigurer -> jwtAuthenticationConverter())) ;return http.build();}/*** 不走过滤器链的放行配置*/@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {return (web) -> web.ignoring().requestMatchers("/webjars/**","/doc.html","/swagger-resources/**","/v3/api-docs/**","/swagger-ui/**");}/*** 自定义JWT Converter** @return Converter* @see JwtAuthenticationProvider#setJwtAuthenticationConverter(Converter)*/@Beanpublic Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();jwtGrantedAuthoritiesConverter.setAuthorityPrefix(Strings.EMPTY);jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(SecurityConstants.AUTHORITIES_CLAIM_NAME_KEY);JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);return jwtAuthenticationConverter;}
}

认证流程测试

分别启动 youlai-mall 的 youai-auth (认证中心)、youlai-system(系统管理模块)、youali-gateway(网关)

登录认证授权

  • 请求参数

  • 认证参数

    Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),

  • 成功响应

    认证成功,获取到访问令牌(access_token )

获取用户信息

使用已获得的访问令牌 (access_token) 向资源服务器发送请求以获取登录用户信息

在这里插入图片描述

成功地获取登录用户信息的响应,而不是出现未授权的401错误。

结语

关于 Spring Authorization Server 1.1 版本的密码模式扩展和在 Spring Cloud 中使用新的授权方式,可以说与 Spring Security OAuth2 的代码相似度极高。如果您已经熟悉 Spring Security OAuth2,那么学习 Spring Authorization Server 将变得轻而易举。后续文章会更新其他常见授权模式的扩展,敬请期待~

源码

本文完整源码: youlai-mall

参考文档

  • Spring Security 弃用 授权服务器和资源服务器

  • Spring Security OAuth 生命周期终止通知

    Spring Security OAuth 2.0 更新路线图

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/170786.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

渗透测试工具-sqlmap使用

sqlmap是一个开源渗透测试的自动化工具&#xff0c;可以自动检测和利用SQL注入漏洞并接管数据库服务器。它配备了一个强大的检测引擎&#xff0c;许多用于终极渗透测试的利基功能&#xff0c;以及广泛的开关&#xff0c;包括数据库指纹识别、从数据库中获取数据、访问底层文件系…

mysql-linux归档版安装

什么是归档版安装&#xff1f;简单来说就是编译好的软件压缩打包版。 说明&#xff1a;我这里服务器之前已经装过一个不同版本的mysql&#xff0c;已经占用了3306端口&#xff0c;所以这里我用3307端口来演示&#xff0c;命令和官方的稍有不同&#xff0c;不过步骤都是差不多的…

MES管理系统解决方案实现生产信息全程追溯

生产制造企业在生产过程中&#xff0c;最令人头疼的问题之一就是产品信息的追溯。当产品出现质量问题时&#xff0c;需要在庞大的数据中寻找出问题批次的产品和同批次的物料&#xff0c;并进行召回处理&#xff0c;这样的工作量十分巨大。为了解决这一问题&#xff0c;许多企业…

树莓派4b,应用窗口/最小化后在任务栏不显示

最近树莓派价格下降&#xff0c;入手了一个树莓派4B 8G版&#xff0c;安装的是64位的官方bullseye系统&#xff0c; 和自己家里吃灰的树莓派2b比较起来&#xff0c;真是香太多&#xff5e; 由于还不熟悉新系统&#xff0c;一不小心把任务栏给删掉了&#xff0c; 重新添加任务栏…

4.1 网络基础之网络IO

一、编写基本服务程序流程 下面介绍一个最最简单的服务程序的编写流程&#xff0c;先按照顺序介绍各个函数的参数和使用。然后在第三节用一对简单的程序对客户端与服务端通信过程进行演示。下面所有代码均在linux平台实现&#xff0c;所以可能与windows上的编程有所区别&#…

app测试和web测试有什么区别

1.性能方面: web页面可能更关注响应时间&#xff0c;而app更关注流量、电量、QPS。 2.系统架构方面&#xff1a; web项目&#xff0c;一般都是b/s架构&#xff0c;基于浏览器的&#xff0c;而app则是c/s的&#xff0c;必须要有客户端。在系统测试的时候就会产生区别了。首从…

虹科直播 | CDS网络与数据安全专题技术直播重磅来袭,11.2起与您精彩相约

文章来源&#xff1a;虹科网络安全 阅读原文&#xff1a;https://mp.weixin.qq.com/s/T-CgU28hmYy4YV5SV9QGhg 虹科数据加密解决方案 虹科终端安全防护方案 虹科是在各细分专业技术领域内的资源整合及技术服务落地供应商&#xff0c;虹科网络安全事业部的宗旨是&#xff1a;让…

mac电脑怎么永久性彻底删除文件?

Mac老用户都知道在我们查看Mac内存时都会发现有一条“其他文件”占比非常高&#xff0c;它是Mac储存空间中的“其他”数据包含不可移除的移动资源&#xff0c;如&#xff0c;Siri 语音、字体、词典、钥匙串和 CloudKit 数据库、系统无法删除缓存的文件等。这些“其他文件”无用…

红米电脑硬盘剪切

Redmi R14 2023版固态硬盘剪切 工具准备操作结尾语 首先要说明&#xff0c;本文所说的操作不一定适合你的电脑&#xff0c;因为电子产品更新换代过快&#xff0c;你的硬盘不一定能剪切&#xff0c;在操作前一定要仔细观察硬盘的型号&#xff0c;是否为同款&#xff0c;我上了图…

如何保障单病种上报的填报效率、质量监控及数据安全

在国家平台对单病种病例进行手工直报&#xff0c;是大多数医院最初获知《关于进一步加强单病种质量管理与控制工作的通知》后的首选方式。随着医院对上报流程与内容的逐步熟练&#xff0c;质控管理的需求开始凸显并占据主要地位&#xff0c;同时为了能更好地适应国家平台的频繁…

测试用例的设计方法(全):等价类划分方法

一.方法简介 1.定义 是把所有可能的输入数据,即程序的输入域划分成若干部分&#xff08;子集&#xff09;,然后从每一个子集中选取少数具有代表性的数据作为测试用例。该方法是一种重要的,常用的黑盒测试用例设计方法。 2.划分等价类&#xff1a; 等价类是指某个输入域的…

【PG】PostgreSQL客户端认证pg_hba.conf文件

目录 文件格式 连接类型(TYPE) 数据库&#xff08;database&#xff09; 用户(user) 连接地址&#xff08;address&#xff09; 格式 IPv4 IPv6 字符 主机名 主机名后缀 IP-address/IP-mask auth-method trust reject scram-sha-256 md5 password gss sspi …

正点原子嵌入式linux驱动开发——外置RTC芯片PCF8563

上一章学习了STM32MP1内置RTC外设&#xff0c;了解了Linux系统下RTC驱动框架。一般的应用场合使用SOC内置的RTC就可以了&#xff0c;而且成本也低&#xff0c;但是在一些对于时间精度要求比较高的场合&#xff0c;SOC内置的RTC就不适用了。这个时候需要根据自己的应用要求选择合…

VR全景拍摄市场需求有多大?适用于哪些行业?

随着VR全景技术的成熟&#xff0c;越来越多的商家开始借助VR全景来宣传推广自己的店铺&#xff0c;特别是5G时代的到来&#xff0c;VR全景逐渐被应用在我们的日常生活中的各个方面&#xff0c;VR全景拍摄的市场需求也正在逐步加大。 通过VR全景技术将线下商家的实景“搬到线上”…

HTML5语义化标签 header 的详解

&#x1f31f;&#x1f31f;&#x1f31f; 专栏详解 &#x1f389; &#x1f389; &#x1f389; 欢迎来到前端开发之旅专栏&#xff01; 不管你是完全小白&#xff0c;还是有一点经验的开发者&#xff0c;在这里你会了解到最简单易懂的语言&#xff0c;与你分享有关前端技术和…

SILKYPIX Developer Studio Pro 11E for Mac: 掌握数码照片处理的黄金标准

在当今的数字时代&#xff0c;照片处理已经成为我们日常生活的一部分。无论是社交媒体分享&#xff0c;还是个人相册制作&#xff0c;我们总是希望我们的照片能够展现出最佳的效果。然而&#xff0c;这并非易事。幸运的是&#xff0c;SILKYPIX Developer Studio Pro 11E for Ma…

redis缓存击穿 穿透

我们之前写了一把分布式锁 并且用redis写的, redis内部实现是比较完善的&#xff0c;但是我们公司用的时候 redis 至少都是主从&#xff0c;哨兵,cluster 很少有单机的 呢么我们分布式锁基于集群问题下会有什么问题 比如说当第一个线程设置一个key过来进行加锁&#xff0c;加锁…

云计算模式的区域LIS系统源码,基于ASP.NET+JQuery、EasyUI+MVC技术架构开发

云计算模式的区域LIS系统源码 云LIS系统源码&#xff0c;自主版权 LIS系统是专为医院检验科的仪器设备能与计算机连接。可通过LIS系统向仪器发送指令&#xff0c;让仪器自动操作和接收仪器数据。并快速的将检验仪器中的数据导入到医生工作站中进行管理&#xff0c;且可将检验结…

CI/CD:GitLab-CI 自动化集成/部署 JAVA微服务的应用合集

CI/CD&#xff1a;GitLab-CI 自动化集成/部署 JAVA微服务的应用合集 CI/CD&#xff1a;GitLab-CI 自动化集成/部署 JAVA微服务的应用合集安装DockerGitLabGitLab-Runner阿里云容器仓库 GitLab-CIJava微服务的GitLab-CI应用 其他问题Maven本地仓库缓存 CI/CD&#xff1a;GitLab-…

贪心算法学习——最大数

目录 ​编辑 一&#xff0c;题目 二&#xff0c;题目接口 三&#xff0c;解题思路级代码 一&#xff0c;题目 给定一组非负整数 nums&#xff0c;重新排列每个数的顺序&#xff08;每个数不可拆分&#xff09;使之组成一个最大的整数。 注意&#xff1a;输出结果可能非常大…