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
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency></dependencies>
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.0 http // 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<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); } 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<SecurityContext> 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:off public 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:on public static JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { Set<JWSAlgorithm> jwsAlgs = new HashSet<>(); jwsAlgs.addAll(JWSAlgorithm.Family.RSA); jwsAlgs.addAll(JWSAlgorithm.Family.EC); jwsAlgs.addAll(JWSAlgorithm.Family.HMAC_SHA); ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>(); JWSKeySelector<SecurityContext> jwsKeySelector = new JWSVerificationKeySelector<>(jwsAlgs, jwkSource); jwtProcessor.setJWSKeySelector(jwsKeySelector); // Override the default Nimbus claims set verifier as NimbusJwtDecoder handles it instead jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { }); return new NimbusJwtDecoder(jwtProcessor); } @Bean RegisterMissingBeanPostProcessor 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; /** * Spring Security 的过滤器链,用于 Spring Security 的身份认证 */ @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize // 配置放行的请求 .antMatchers("/api/**", "/login").permitAll() // 其他任何请求都需要认证 .anyRequest().authenticated() ) // 设置登录表单页面 .formLogin(formLoginConfigurer -> formLoginConfigurer.loginPage("/login")); return http.build(); } // @Bean// public UserDetailsService userDetailsService() {// return new JdbcUserDetailsManager(dataSource);// } @Bean UserDetailsManager userDetailsManager() { return new JdbcUserDetailsManager(dataSource); }}
Spring Authorization Server 默认是支持内存和 JDBC 两种存储模式的,内存模式只适合简单的测试,所以这里使用 JDBC 存储模式。在 1.2.1 最小配置那节里注入 UserDetailsService
这个 Bean 使用的是 InMemoryUserDetailsManager
,表示内存模式,这里使用 JdbcUserDetailsManager
表示 JDBC 模式
而这两个类都属于 UserDetailsManager
接口的实现类,并且后续我们需要使用到 userDetailsManager.createUser(userDetails)
方法来添加用户,因此这里需要注入 UserDetailsManager
这个 Bean,由于返回的都是 JdbcUserDetailsManager,因此可以注释掉 UserDetailsService
这个 Bean 的注入
1.2.3 AuthorizationServerConfig
该类部分配置可以参照前面提到的 OAuth2AuthorizationServerConfiguration
类来配置,同样使用 JDBC 存储模式
@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(Customizer.withDefaults()); // 获取授权服务器相关的请求端点 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(); } /** * 注册客户端应用, 对应 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); } /** * 加载 JWT 资源, 用于生成令牌 */ @Bean public JWKSource<SecurityContext> 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); } /** * JWT 解码 */ @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } /** * AuthorizationServerS 的相关配置 */ @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));
/*IMPORTANT: If using PostgreSQL, update ALL columns defined with 'blob' to 'text', as PostgreSQL does not support the 'blob' data type.*/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
<!DOCTYPE html><html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <title>Spring Security Example</title> <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous"> <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/></head><body><div class="container"> <form class="form-signin" method="post" th:action="@{/login}"> <div th:if="${param.error}" class="alert alert-danger" role="alert"> 用户名或密码无效 </div> <div th:if="${param.logout}" class="alert alert-success" role="alert"> 您已注销 </div> <h2 class="form-signin-heading">登录</h2> <p> <label for="username" class="sr-only">用户名</label> <input type="text" id="username" name="username" class="form-control" placeholder="用户名" required autofocus> </p> <p> <label for="password" class="sr-only">密 码</label> <input type="password" id="password" name="password" class="form-control" placeholder="密 码" required> </p> <button class="btn btn-lg btn-primary btn-block" type="submit">登录</button> <a class="btn btn-light btn-block bg-white" href="/oauth2/authorization/github-idp" role="link" style="text-transform: none;"> <img width="24" style="margin-right: 5px;" alt="Sign in with GitHub" src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" /> 使用Github登录 </a> </form></div></body></html>
创建 LoginConroller,用于跳转到 login.html 页面
@Controllerpublic class LoginController { @GetMapping("/login") public String login() { return "login"; }}
授权页面 consent.html
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous"> <title>Custom consent page - Consent required</title> <style> body { background-color: aliceblue; } </style> <script> function cancelConsent() { document.consent_form.reset(); document.consent_form.submit(); } </script></head><body><div class="container"> <div class="py-5"> <h1 class="text-center text-primary">应用程序权限</h1> </div> <div class="row"> <div class="col text-center"> <p> 应用程序 <span class="font-weight-bold text-primary" th:text="${clientId}"></span> 想要访问您的帐户 <span class="font-weight-bold" th:text="${principalName}"></span> </p> </div> </div> <div class="row pb-3"> <div class="col text-center"><p>上述应用程序请求以下权限<br>如果您批准,请查看这些并同意</p></div> </div> <div class="row"> <div class="col text-center"> <form name="consent_form" method="post" th:action="@{/oauth2/authorize}"> <input type="hidden" name="client_id" th:value="${clientId}"> <input type="hidden" name="state" th:value="${state}"> <div th:each="scope: ${scopes}" class="form-group form-check py-1"> <input class="form-check-input" type="checkbox" name="scope" th:value="${scope.scope}" th:id="${scope.scope}"> <label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label> <p class="text-primary" th:text="${scope.description}"></p> </div> <p th:if="${not #lists.isEmpty(previouslyApprovedScopes)}">您已向上述应用授予以下权限:</p> <div th:each="scope: ${previouslyApprovedScopes}" class="form-group form-check py-1"> <input class="form-check-input" type="checkbox" th:id="${scope.scope}" disabled checked> <label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label> <p class="text-primary" th:text="${scope.description}"></p> </div> <div class="form-group pt-3"> <button class="btn btn-primary btn-lg" type="submit" id="submit-consent"> 提交授权 </button> </div> <div class="form-group"> <button class="btn btn-link regular" type="button" id="cancel-consent" onclick="cancelConsent();"> 取消 </button> </div> </form> </div> </div> <div class="row pt-4"> <div class="col text-center"> <p> <small> Your consent to provide access is required. <br/>If you do not approve, click Cancel, in which case no information will be shared with the app. </small> </p> </div> </div></div></body></html>
创建 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<String> scopesToApprove = new HashSet<>(); Set<String> previouslyApprovedScopes = new HashSet<>(); // 查询 clientId 是否存在 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; } // 如果已授权范围包含了请求范围,则添加到以前批准的范围的 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<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 = "未知范围 - 我们无法提供有关此权限的信息, 请在授予此权限时谨慎"; private static final Map<String, String> 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<String, Object> 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 引入依赖
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency></dependencies>
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 { /** * 资源管理器配置 * * @param http * @return {@link SecurityFilterChain} * @author Fan * @since 2023/2/2 9:30 */ @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/**").permitAll() // 其他任何请求都需要认证 .anyRequest().authenticated()) // 异常处理器 .exceptionHandling(exceptionConfigurer -> exceptionConfigurer // 认证失败 .authenticationEntryPoint(authenticationEntryPoint) // 鉴权失败 .accessDeniedHandler(accessDeniedHandler) ) // 资源服务 .oauth2ResourceServer(resourceServer -> resourceServer .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) .jwt()); return http.build(); }}
2.5 Controller
@RestControllerpublic class MessagesController { @GetMapping("/resource1") public Response getResource1(){ return Response.success("服务A -> 资源1 -> 读权限"); } @GetMapping("/resource2") public Response getResource2(){ return Response.success("服务A -> 资源2 -> 写权限"); } @GetMapping("/resource3") public Response resource3(){ return Response.success("服务A -> 资源3 -> profile 权限"); } @GetMapping("/api/publicResource") public Response publicResource() { return Response.success("服务A -> 公共资源"); }}
2.6 测试
完整目录结构如下:
启动项目,打开 Apifox,直接请求时,会提示我们认证失败,即上面认证失败处理器的响应结果
添加 Auth,类型选择 Bearer Token,Token 的值即为前面获取到的 access_token 的值
保存,发送后,即可获取资源 resource1
再获取资源 resource2,提示没有权限,这里返回的信息即为鉴权失败处理器的响应信息。因为在我们申请权限的时候只申请了 message.read 权限,同时也只授权了 message.read 权限,而 resource2 需要 message.write 权限,因此鉴权失败,无法访问
3. 客户端
3.1 引入依赖
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency></dependencies>
3.2 YAML 配置
server: port: 8000spring: application: name: messages-client security: oauth2: client: registration: messaging-client-oidc: provider: authorization-server client-id: messaging-client client-secret: secret authorization-grant-type: authorization_code# redirect-uri: "127.0.0.1:8000/login/oauth2/code/messaging-client-oidc" redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}" scope: openid,message.read,message.write client-name: messaging-client-oidc provider: # 服务提供地址 authorization-server: # issuer-uri 可以简化下面的配置 issuer-uri: http://localhost:9000 # 请求授权码地址# authorization-uri: http://localhost:9000/oauth2/authorize # 请求令牌地址# token-uri: http://localhost:9000/oauth2/token # 用户资源地址# user-info-uri: http://localhost:9000/oauth2/user # 用户资源返回中的一个属性名# user-name-attribute: name# user-info-authentication-method: GET
这里的配置要和注册客户端时的配置对应上,同一颜色对应,这里使用的是 OIDC,scope 加上了 openid
注意:使用 OIDC 是为了使用默认的用户端点,假如不使用 OIDC 需要自定义用户端点接口,否则会报如下错误
[invalid_user_info_response] An error occurred while attempting to retrieve the UserInfo Resource: 403 : “{“error”:“insufficient_scope”}”
3.3 配置类
@EnableWebSecurity@Configuration(proxyBeanMethods = false)public class ClientSecurityConfig { /** * 安全配置 */ @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
<!DOCTYPE html><html lang="en" xmlns:th="http://www.thymeleaf.org"><head> <meta charset="UTF-8"> <title>Title</title></head><body>登录用户:<span th:text="${user}"></span><hr/><ul> <li><a href="./server/a/resource1">服务A —— 资源1</a></li> <li><a href="./server/a/resource2">服务A —— 资源2</a></li> <li><a href="./server/a/resource3">服务A —— 资源3</a></li> <li><a href="./server/a/publicResource">服务A —— 公共资源</a></li></ul></body></html>
创建 IndexController,跳转到 index.html
@Controllerpublic class IndexController { @GetMapping("/") public String root() { return "redirect:/index"; } @GetMapping("/index") public String index(Model model) { Map<String, Object> 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); } /** * 绑定token,请求微服务 */ private String getServer(String url, OAuth2AuthorizedClient oAuth2AuthorizedClient) { LogUtil.info("getServer"); // 获取 access_token String tokenValue = oAuth2AuthorizedClient.getAccessToken().getTokenValue(); // 发起请求 Mono<String> 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 信息