行业知识
Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战
开局一张图
项���源码:youlai-mall
通过 Spring Cloud Gateway 访问认证中心进行认证并获取得到访问令牌。
再根据访问令牌 access_token 获取当前登录的用户信息。
前言
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 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 添加授权服务器依赖
org.springframework.security spring-security-oauth2-authorization-server 1.1.1
application.yml
认证中心配置 oauth2_server 数据库连接信息
spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-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=true username: root password: 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@Slf4jpublic class AuthorizationServerConfig { private final OAuth2TokenCustomizer 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 ->// authenticationConverters.addAll( // 自定义授权模式转换器(Converter) List.of( new PasswordAuthenticationConverter() ) ) ) .authenticationProviders(authenticationProviders ->// 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 // public JWKSource jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); // @formatter:off RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); // @formatter:on JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet(jwkSet); } 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; } @Bean public JwtDecoder jwtDecoder(JWKSource jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } @Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } @Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); // 初始化 OAuth2 客户端 initMallAppClient(registeredClientRepository); initMallAdminClient(registeredClientRepository); return registeredClientRepository; } @Bean public 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 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; } @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { // Will be used by the ConsentController return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); } @Bean OAuth2TokenGenerator tokenGenerator(JWKSource 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); } @Bean public 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 自定义安全配置 */ @Bean public 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 scopes; /** * 密码模式身份验证令牌 * * @param clientPrincipal 客户端信息 * @param scopes 令牌申请访问范围 * @param additionalParameters 自定义额外参数(用户名和密码) */ public PasswordAuthenticationToken( Authentication clientPrincipal, Set scopes, @Nullable Map additionalParameters ) { super(PASSWORD, clientPrincipal, additionalParameters); this.scopes = Collections.unmodifiableSet(scopes != null ? new HashSet(scopes) : Collections.emptySet()); } /** * 用户凭证(密码) */ @Override public Object getCredentials() { return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD); } public Set getScopes() { return scopes; }}PasswordAuthenticationConverter
package com.youlai.auth.authentication.password;/** * 密码模式参数解析器 *
* 解析请求参数中的用户名和密码,并构建相应的身份验证(Authentication)对象 * * @author haoxr * @see org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter * @since 3.0.0 */public class PasswordAuthenticationConverter implements AuthenticationConverter { @Override public 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 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 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 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;/** * 密码模式身份验证提供者 *
* 处理基于用户名和密码的身份验证 * * @author haoxr * @since 3.0.0 */@Slf4jpublic 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
- 参考 Spring Authorization Server 官方示例 demo-authorizationserver 下的 DefaultSecurityConfig.java 进行安全配置