title: OAuth2.0 实践 Spring Authorization Server 搭建授权服务器 + Resource + Client
date: 2023-03-27 01:41:26
tags:
- OAuth2.0
- Spring Authorization Server
categories: - 开发实践
cover: https://cover.png
feature: false
1. 授权服务器
目前 Spring 生态中的 OAuth2 授权服务器是 Spring Authorization Server,原先的 Spring Security OAuth 已经停止更新
1.1 引入依赖
这里的 spring-security-oauth2-authorization-server
用的是 0.4.0 版本,适配 JDK 1.8,Spring Boot 版本为 2.7.7
org.springframework.boot spring-boot-starter-security org.springframework.security spring-security-oauth2-authorization-server org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-jdbc mysql mysql-connector-java
1.2 配置类
可以参考官方的 Samples:spring-authorization-server/samples
1.2.1 最小配置
官网最小配置 Demo 地址:Getting Started
官网最小配置如下,通过添加该配置类,启动项目,这就能够完成 OAuth2 的授权
@Configurationpublic class SecurityConfig {@Bean @Order(1)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)throws Exception {OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());// Enable OpenID Connect 1.0http// Redirect to the login page when not authenticated from the// authorization endpoint.exceptionHandling((exceptions) -> exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))// Accept access tokens for User Info and/or Client Registration.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);return http.build();}@Bean @Order(2)public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)throws Exception {http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())// Form login handles the redirect to the login page from the// authorization server filter chain.formLogin(Customizer.withDefaults());return http.build();}@Bean public UserDetailsService userDetailsService() {UserDetails userDetails = User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build();return new InMemoryUserDetailsManager(userDetails);}@Bean public RegisteredClientRepository registeredClientRepository() {RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()).clientId("messaging-client").clientSecret("{noop}secret").clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC).authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN).authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS).redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc").redirectUri("http://127.0.0.1:8080/authorized").scope(OidcScopes.OPENID).scope(OidcScopes.PROFILE).scope("message.read").scope("message.write").clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();return new InMemoryRegisteredClientRepository(registeredClient);}@Bean public JWKSource 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);}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();}}
在上面的 Demo 里,将所有配置都写在了一个配置类 SecurityConfig 里,实际上 Spring Authorization Server 还提供了一种实现最小配置的默认配置形式,就是通过 OAuth2AuthorizationServerConfiguration
这个类,源码如下:
@Configuration(proxyBeanMethods = false)public class OAuth2AuthorizationServerConfiguration {@Bean@Order(Ordered.HIGHEST_PRECEDENCE)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {applyDefaultSecurity(http);return http.build();}// @formatter:offpublic static void applyDefaultSecurity(HttpSecurity http) throws Exception {OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =new OAuth2AuthorizationServerConfigurer();RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();http.requestMatcher(endpointsMatcher).authorizeRequests(authorizeRequests ->authorizeRequests.anyRequest().authenticated()).csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)).apply(authorizationServerConfigurer);}// @formatter:onpublic static JwtDecoder jwtDecoder(JWKSource jwkSource) {Set jwsAlgs = new HashSet<>();jwsAlgs.addAll(JWSAlgorithm.Family.RSA);jwsAlgs.addAll(JWSAlgorithm.Family.EC);jwsAlgs.addAll(JWSAlgorithm.Family.HMAC_SHA);ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>();JWSKeySelector jwsKeySelector =new JWSVerificationKeySelector<>(jwsAlgs, jwkSource);jwtProcessor.setJWSKeySelector(jwsKeySelector);// Override the default Nimbus claims set verifier as NimbusJwtDecoder handles it insteadjwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {});return new NimbusJwtDecoder(jwtProcessor);}@BeanRegisterMissingBeanPostProcessor registerMissingBeanPostProcessor() {RegisterMissingBeanPostProcessor postProcessor = new RegisterMissingBeanPostProcessor();postProcessor.addBeanDefinition(AuthorizationServerSettings.class, () -> AuthorizationServerSettings.builder().build());return postProcessor;}}
这里注入一个叫做 authorizationServerSecurityFilterChain
的 bean,其实对比一下可以看出,这和最小配置的实现基本是相同的。有了这个 bean,就会支持如下协议端点:
- OAuth2 Authorization endpoint
- OAuth2 Token endpoint
- OAuth2 Token Introspection endpoint
- OAuth2 Token Revocation endpoint
- OAuth2 Authorization Server Metadata endpoint
- JWK Set endpoint
- OpenID Connect 1.0 Provider Configuration endpoint
- OpenID Connect 1.0 UserInfo endpoint
接下来使用 OAuth2AuthorizationServerConfiguration
这个类来实现一个 Authorization Server,将 Spring Security 和 Authorization Server 的配置分开,Spring Security 使用 SecurityConfig
类,创建一个新的Authorization Server 配置类 AuthorizationServerConfig
1.2.2 ServerSecurityConfig
@EnableWebSecurity@Configuration(proxyBeanMethods = false)public class ServerSecurityConfig { @Resource private DataSource dataSource; @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize // 配置放行的请求 .antMatchers("/api @Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { return new JdbcRegisteredClientRepository(jdbcTemplate); } @Bean public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); } @Bean public JWKSource jwkSource() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } 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 (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } @Bean public JwtDecoder jwtDecoder(JWKSource jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); }}
1.3 创建数据库表
一共包括 5 个表,其中 Spring Security 相关的有 2 个表,user 和 authorities,用户表和权限表,该表的建表 SQL 在
org\springframework\security\core\userdetails\jdbc\users.ddl
SQL 可能会有一些问题,根据自己使用的数据库进行更改
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));create unique index ix_auth_username on authorities (username,authority);
Spring authorization Server 有 3 个表,建表 SQL 在:
org\springframework\security\oauth2\server\authorization\oauth2-authorization-consent-schema.sql
org\springframework\security\oauth2\server\authorization\oauth2-authorization-schema.sql
org\springframework\security\oauth2\server\authorization\client\oauth2-registered-client-schema.sql
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));
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, PRIMARY KEY (id));
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, scopes varchar(1000) NOT NULL, client_settings varchar(2000) NOT NULL, token_settings varchar(2000) NOT NULL, PRIMARY KEY (id));
创建完成后的数据库表如下:
1.4 自定义登录和授权页面
在项目 resource 目录下创建一个 templates 文件夹,然后创建 login.html 和 consent.html,登录页面的配置在 1.2.2 中配置好了,授权页面的配置在 1.2.3 中配置好了
登录页面 login.html
Spring Security Example
创建 LoginConroller,用于跳转到 login.html 页面
@Controllerpublic class LoginController {@GetMapping("/login")public String login() {return "login";}}
授权页面 consent.html
Custom consent page - Consent required 应用程序权限
应用程序 想要访问您的帐户
上述应用程序请求以下权限
如果您批准,请查看这些并同意
Your consent to provide access is required.
If you do not approve, click Cancel, in which case no information will be shared with the app.
创建 AuthorizationConsentController,用于跳转到 consent.html 页面
@Controllerpublic 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) {// 要批准的范围和以前批准的范围Set scopesToApprove = new HashSet<>();Set previouslyApprovedScopes = new HashSet<>();// 查询 clientId 是否存在RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);// 查询当前的授权许可OAuth2AuthorizationConsent currentAuthorizationConsent =this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());// 已授权范围Set authorizedScopes;if (currentAuthorizationConsent != null) {authorizedScopes = currentAuthorizationConsent.getScopes();} else {authorizedScopes = Collections.emptySet();}for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {if (OidcScopes.OPENID.equals(requestedScope)) {continue;}// 如果已授权范围包含了请求范围,则添加到以前批准的范围的 Set, 否则添加到要批准的范围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());return "consent";}private static Set withDescription(Set scopes) {Set scopeWithDescriptions = new HashSet<>();for (String scope : scopes) {scopeWithDescriptions.add(new ScopeWithDescription(scope));}return scopeWithDescriptions;}public static class ScopeWithDescription {private static final String DEFAULT_DESCRIPTION = "未知范围 - 我们无法提供有关此权限的信息, 请在授予此权限时谨慎";private static final Map scopeDescriptions = new HashMap<>();static {scopeDescriptions.put(OidcScopes.PROFILE,"此应用程序将能够读取您的个人资料信息");scopeDescriptions.put("message.read","此应用程序将能够读取您的信息");scopeDescriptions.put("message.write","此应用程序将能够添加新信息, 它还可以编辑和删除现有信息");scopeDescriptions.put("other.scope","这是范围描述的另一个范围示例");}public final String scope;public final String description;ScopeWithDescription(String scope) {this.scope = scope;this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION);}}}
1.5 ServerController
用于添加用户信息和客户端信息,这里的 passwordEncoder 使用 BCryptPasswordEncoder 进行加解密,{bcrypt} 表示加密,{noop} 表示明文
@RestControllerpublic class ServerController { @Resource private UserDetailsManager userDetailsManager; @GetMapping("/api/addUser") public String addUser() { UserDetails userDetails = User.builder().passwordEncoder(s -> "{bcrypt}" + new BCryptPasswordEncoder().encode(s)) .username("fan") .password("fan") .roles("ADMIN") .build(); userDetailsManager.createUser(userDetails); return "添加用户成功"; } @Resource private RegisteredClientRepository registeredClientRepository; @GetMapping("/api/addClient") public String addClient() { // JWT(Json Web Token)的配置项:TTL、是否复用refreshToken等等 TokenSettings tokenSettings = TokenSettings.builder() // 令牌存活时间:2小时 .accessTokenTimeToLive(Duration.ofHours(2)) // 令牌可以刷新,重新获取 .reuseRefreshTokens(true) // 刷新时间:30天(30天内当令牌过期时,可以用刷新令牌重新申请新令牌,不需要再认证) .refreshTokenTimeToLive(Duration.ofDays(30)) .build(); // 客户端相关配置 ClientSettings clientSettings = ClientSettings.builder() // 是否需要用户授权确认 .requireAuthorizationConsent(true) .build(); RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) // 客户端ID和密码 .clientId("messaging-client")// .clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("secret")) .clientSecret("{noop}secret") // 授权方法 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) // 授权模式(授权码模式) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) // 刷新令牌(授权码模式) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) // 回调地址:授权服务器向当前客户端响应时调用下面地址, 不在此列的地址将被拒绝, 只能使用IP或域名,不能使用 localhost .redirectUri("http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc") // OIDC 支持 .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) // 授权范围(当前客户端的授权范围) .scope("message.read") .scope("message.write") // JWT(Json Web Token)配置项 .tokenSettings(tokenSettings) // 客户端配置项 .clientSettings(clientSettings) .build(); registeredClientRepository.save(registeredClient); return "添加客户端信息成功"; }}
1.6 YAML 配置
配置数据库连接信息
server: port: 9000spring: datasource: url: jdbc:mysql://localhost:3306/unified_certification?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root
1.7 测试
完整目录结构如下:
1.7.1 添加用户和客户端信息
启动项目,访问 http://127.0.0.1:9000/api/addUser
查询数据库 users 和 authorities 表,已有用户和权限信息
访问 http://127.0.0.1:9000/api/addClient
查询数据库 oauth2_registered_client 表,已有客户端信息
1.7.2 授权码模式获取令牌
有关 OAuth2.0 的相关知识可见:OAuth2.0 实战总结_凡 223 的博客
访问 http://127.0.0.1:9000/oauth2/authorize?response_type=code&client_id=messaging-client&scope=message.read&redirect_uri=http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc
,这里的 127.0.0.1:8000 其实为客户端地址,后面讲到客户端时,客户端的地址就为 8000
- response_type:授权类型,code 为授权码模式
- client_id:客户端 ID,即前面注册客户端的时候定义的
- scope:请求的权限范围
- redirect_uri:回调地址,也是前面注册客户端的时候定义的
未登录,会跳转到登录页面
输入前面添加的用户信息,用户名和密码,然后会跳转到授权页面
选择是否授予权限,这里勾选后,点击提交,会跳转到回调地址,即 127.0.0.1:8000/login/oauth2/code/messaging-client-oidc,由于这个地址还没有对应的服务,无法访问,但我们暂时需要的是地址栏的 code
http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc?code=z_3O1lEdxVsd2fn8_uKA481pO9caGd0N4x_Vbt0deuMA77sDis6fhMJkf2_9uM4KGYzLzv7ujbXZ2JAdg0ACyMapR38jnJruG2iz2XBgptKrru-IJobGVa6NTicgvCZ7
打开接口测试工具,这里我使用的是 Apifox,使用表单格式,包含三个参数
- grant_type:授权类型,authorization_code 表示授权码模式
- code:即授权码,上面地址栏里返回给我们的 code 部分,复制到这里,code 使用一次就会失效
- redirect_uri:回调地址,与前面的一致。图中的地址忘记修改了,注意和前面请求 code 时写的回调地址一致,即 http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc,后面有类似问题同样修改
然后设置 Auth,Postman 里是 Authorization,选择 Basic Auth 类型,用户名密码则为注册客户端时的 client_id 和 clientSecret,客户端 ID 和密钥
保存,发送后,会给我们返回 access_token 和 refresh_token
将 access_token 复制到 JSON Web Tokens - jwt.io 网站,解析后可以看到 JWT 的信息,包括客户端 ID,权限范围,服务器地址等
1.7.3 授权码模式刷新令牌
在前面返回了 access_token 和 refresh_token,access_token 包含了授权信息,refresh_token 则是用来重新获取 access_token,同样是表单类型,包含两个参数
- grant_type:refresh_token 表示刷新令牌
- refresh_token:即前面获取到的 refresh_token 的值
Auth 信息与前面一致
保存,发送后,会给我们返回新的 access_token 和 refresh_token,refresh_token 使用一次就会失效
1.7.4 客户端模式
同样使用表单格式,grant_type 值为 client_credentials
Auth 与前面一致
保存,发送后,会给我们返回 access_token,没有 refresh_token。因为在授权码模式中的 access_token 是我们通过授权码 code 换来的,而授权码 code 是我们请求后授权得到的,为了不用每次获取 access_token 都需要重新请求授权,所以使用 refresh_token 来重新获取 access_token,refresh_token 和 access_token 都有过期时间,refresh_token 过期时间比 access_token 长
而客户端模式可以直接获取 access_token,所以也就不需要 refresh_token 了
1.7.5 OIDC
有关 OIDC 的相关知识同样可见:OAuth2.0 实战总结_凡 223 的博客
在前面 1.2.3 的配置和 1.5 的注册客户端时,已经支持了 OIDC,这里直接访问:http://127.0.0.1:9000/oauth2/authorize?response_type=code&client_id=messaging-client&scope=openid&redirect_uri=http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc
这里的 scope 必须包含 openid
得到授权码 code
http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc?code=NjvT1z3msYRsjvPPM4LP4EmlyBUixsKes_J6osSB3VAugXEKmyUappvtrmTWp7s_iQzoJsD8xOE3gUXawhMixL0fu2HC6UJv8CeZyCB-d2oiu4NnCO9uJcK1MXOm4poU
然后通过授权码 code 换取令牌,可以看到除了 access_token 和 refresh_token 外,还返回了一个 id_token
解析这个 id_token,信息如下,是我们的身份认证信息
再通过 refresh_token 重新获取令牌,同样也给我们返回了 id_token
通过 access_token,获取 OIDC 的用户端点
这里的 sub 就是用户的标志。在 1.2.3 的配置中,对于 OIDC 使用的是默认配置
我们也可以增加自定义信息,修改后的配置如下,其他配置不变
@Configuration(proxyBeanMethods = false)public class AuthorizationServerConfig { private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent"; @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { // 定义授权服务配置器 OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer(); configurer // 自定义授权页面 .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)) // Enable OpenID Connect 1.0, 启用 OIDC 1.0 .oidc(oidcConfigurer -> oidcConfigurer.userInfoEndpoint(userInfoEndpointConfigurer -> userInfoEndpointConfigurer.userInfoMapper(userInfoAuthenticationContext -> {OAuth2AccessToken accessToken = userInfoAuthenticationContext.getAccessToken();Map claims = MapUtil.map(false);claims.put("url", "http://127.0.0.1:9000");claims.put("accessToken", accessToken);claims.put("sub", userInfoAuthenticationContext.getAuthorization().getPrincipalName());return new OidcUserInfo(claims); }))); // 获取授权服务器相关的请求端点 RequestMatcher endpointsMatcher = configurer.getEndpointsMatcher(); http // 拦截对授权服务器相关端点的请求 .requestMatcher(endpointsMatcher) // 拦载到的请求需要认证 .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) // 忽略掉相关端点的 CSRF(跨站请求): 对授权端点的访问可以是跨站的 .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) // 访问端点时表单登录 .formLogin() .and() // 应用授权服务器的配置 .apply(configurer); return http.build(); } // ... 其他配置不变}
重启项目,重新获取到 access_token,通过 access_token 访问用户端点,可以看到我们自定义的信息已经被添加了进来
2. 资源服务器
2.1 引入依赖
org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-oauth2-resource-server cn.hutool hutool-all
2.2 YAML 配置
server: port: 8001spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000
2.3 异常处理器
该部分为 Spring Security 相关知识,可见:Spring Security 总结_凡 223 的博客
2.3.1 认证失败处理器
Response 为自定义的统一结果返回类,这里的返回信息自定义即可
public class UnAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { // 403, 未授权, 禁止访问 response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setCharacterEncoding("utf-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 返回响应信息 ServletOutputStream outputStream = response.getOutputStream(); Response fail = Response.fail(HttpServletResponse.SC_FORBIDDEN, "UnAccessDeniedHandler-未授权, 不允许访问", "uri-" + request.getRequestURI()); outputStream.write(JSONUtil.toJsonStr(fail).getBytes(StandardCharsets.UTF_8)); // 关闭流 outputStream.flush(); outputStream.close(); }}
2.3.2 鉴权失败处理器
public class UnAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { if (authException instanceof InvalidBearerTokenException) { LogUtil.info("Token 登录失效"); } if (response.isCommitted()) { return; } // 401, 未认证 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setStatus(HttpServletResponse.SC_ACCEPTED); response.setCharacterEncoding("utf-8"); response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 返回响应信息 ServletOutputStream outputStream = response.getOutputStream(); Response fail = Response.fail(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage() + "-UnAuthenticationEntryPoint-认证失败", "uri-" + request.getRequestURI()); outputStream.write(JSONUtil.toJsonStr(fail).getBytes(StandardCharsets.UTF_8)); // 关闭流 outputStream.flush(); outputStream.close(); }}
2.4 配置类
对资源请求配置了读、写、profile 权限
@EnableWebSecurity@Configuration(proxyBeanMethods = false)public class ResourceServerConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { UnAuthenticationEntryPoint authenticationEntryPoint = new UnAuthenticationEntryPoint(); UnAccessDeniedHandler accessDeniedHandler = new UnAccessDeniedHandler(); http // security的session生成策略改为security不主动创建session, 即STALELESS // 资源服务不涉及用户登录, 仅靠token访问, 不需要seesion .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(authorize -> authorize // 对 /resource1 的请求,需要 SCOPE_message.read 权限 .antMatchers("/resource1").hasAuthority("SCOPE_message.read") // 对 /resource2 的请求,需要 SCOPE_message.write 权限 .antMatchers("/resource2").hasAuthority("SCOPE_message.write") // 对 /resource3 的请求,需要 SCOPE_profile 权限 .antMatchers("/resource3").hasAuthority("SCOPE_profile") // 放行请求 .antMatchers("/api @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> // 任何请求都需要认证 authorize.anyRequest().authenticated() ) // 登录// .oauth2Login(oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc")) .oauth2Login(Customizer.withDefaults()) .oauth2Client(Customizer.withDefaults()); return http.build(); }}
3.4 index.html
Title 登录用户:
创建 IndexController,跳转到 index.html
@Controllerpublic class IndexController {@GetMapping("/")public String root() {return "redirect:/index";}@GetMapping("/index")public String index(Model model) {Map map = MapUtil.map(false);Authentication auth = SecurityContextHolder.getContext().getAuthentication();map.put("name", auth.getName());Collection extends GrantedAuthority> authorities = auth.getAuthorities();List extends GrantedAuthority> authoritiesList = authorities.stream().collect(Collectors.toList());map.put("authorities", authoritiesList);model.addAttribute("user", JSONUtil.toJsonStr(map));return "index";}}
3.5 ResourceController
@RestControllerpublic class ResourceController { @GetMapping("/server/a/resource1") public String getServerARes1(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) { return getServer("http://127.0.0.1:8001/resource1", oAuth2AuthorizedClient); } @GetMapping("/server/a/resource2") public String getServerARes2(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) { return getServer("http://127.0.0.1:8001/resource2", oAuth2AuthorizedClient); } @GetMapping("/server/a/resource3") public String getServerBRes1(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) { return getServer("http://127.0.0.1:8001/resource3", oAuth2AuthorizedClient); } @GetMapping("/server/a/publicResource") public String getServerBRes2(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient oAuth2AuthorizedClient) { return getServer("http://127.0.0.1:8001/api/publicResource", oAuth2AuthorizedClient); } private String getServer(String url, OAuth2AuthorizedClient oAuth2AuthorizedClient) { LogUtil.info("getServer"); // 获取 access_token String tokenValue = oAuth2AuthorizedClient.getAccessToken().getTokenValue(); // 发起请求 Mono stringMono = WebClient.builder() .defaultHeader("Authorization", "Bearer " + tokenValue) .build() .get() .uri(url) .retrieve() .bodyToMono(String.class); return stringMono.block(); }}
3.6 测试
完整目录结构如下:
启动项目,访问 127.0.0.1:8000
,未登录会直接跳转到登录页面
输入用户名密码,登录后进入授权页面
选择想要授予的权限,这里勾选 read 权限,点击提交,跳转到我们的首页 index.html
将上面 user 的 JSON 信息格式化一下如下,可以看到就是我们的认证和权限信息
点击访问 服务A -> 资源1
点击访问 服务A -> 资源2,无法访问
这是因为之前授权时只给了 read 权限,而资源 2 需要 write 权限,可以看到报了 403 异常,这里可以定义一个异常处理类,来返回对应的信息,而不是白页
我们关闭当前页面新开一个页面,再次访问 127.0.0.1:8000
可以发现直接进入了 index.html,无需再次登录
可以发现我们访问时是带了一个 JESSEIONID 的,用户登录后,会在认证服务器和客户端都保存 session 信息
来源地址:https://blog.csdn.net/ACE_U_005A/article/details/128851814