一、背景 Oauth2
停止维护,基于OAuth 2.1
和 OpenID Connect 1.0
的Spring Authorization Server
模块独立于SpringCloud
。
本文开发环境如下:
Version
Java
17
SpringCloud
2023.0.0
SpringBoot
3.2.1
Spring Authorization Server
1.2.1
Spring Security
6.2.1
mysql
8.2.0
https://spring.io/projects/spring-security#learn https://spring.io/projects/spring-authorization-server#learn
二、微服务架构介绍 一个认证服务器(也是一个微服务),专门用于颁发JWT。 一个网关(也是一个微服务),用于白名单判断和JWT校验。 若干微服务。
本文的关键在于以下几点:
搭建认证服务器
网关白名单判断
网关验证JWT
认证服务器如何共享公钥,让其余微服务有JWT自校验的能力。
三、认证服务器 这里是官方文档https://spring.io/projects/spring-authorization-server#learn 基本上跟着Getting Started
写完就可以。
1. 数据库创建 新建一个数据库xc_users
。 然后执行jar
里自带的三个sql
。
这一步官方并没有给出,大概因为可以使用内存存储,在简单demo省去了持久化。不建立数据库可能也是可行的,我没试过。
2. 新建模块 新建一个auth
模块,作为认证服务器。
3. 导入依赖和配置 language-xml 1 2 3 4 5 6 7 8 9 10 11 12 <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</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-authorization-server</artifactId> </dependency>
language-yaml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 server: servlet: context-path: /auth port: 63070 spring: application: name: auth-service profiles: active: dev datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.101.65:3306/xc_users?serverTimezone=UTC&userUnicode=true&useSSL=false& username: root password: 1009
4. 安全认证配置类 language-java 1 2 3 4 @Configuration @EnableWebSecurity public class AuthServerSecurityConfig { }
里面包含诸多内容,有来自Spring Security
的,也有来自的Spring Authorization Server
的。
UserDetailsService
的实例,用于检索用户进行身份验证。
language-java 1 2 3 4 5 6 7 8 9 10 @Bean public UserDetailsService userDetailsService() { UserDetails userDetails = User .withUsername("lisi") .password("456") .roles("read") .build(); return new InMemoryUserDetailsManager(userDetails); }
密码编码器(可选,本文不用)
language-java 1 2 3 4 5 6 7 8 @Bean public PasswordEncoder passwordEncoder() { // 密码为明文方式 return NoOpPasswordEncoder.getInstance(); // 或使用 BCryptPasswordEncoder // return new BCryptPasswordEncoder(); }
协议端点的 Spring Security
过滤器链
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @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 .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) // Accept access tokens for User Info and/or Client Registration .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); }
用于身份验证的 Spring Security
过滤器链。 至于哪些要校验身份,哪些不用,根据自己需求写。
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Bean @Order(2) public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/login")).permitAll() .requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/**/*.html")).permitAll() .requestMatchers(new AntPathRequestMatcher("/**/*.json")).permitAll() .requestMatchers(new AntPathRequestMatcher("/auth/**")).permitAll() .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ); return http.build(); }
自定义验证转化器(可选)
language-java 1 2 3 4 5 6 7 private JwtAuthenticationConverter jwtAuthenticationConverter() { JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter(); // 此处可以添加自定义逻辑来提取JWT中的权限等信息 // jwtConverter.setJwtGrantedAuthoritiesConverter(...); return jwtConverter; }
用于管理客户端的 RegisteredClientRepository
实例
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("XcWebApp") // .clientSecret("{noop}XcWebApp") .clientSecret("XcWebApp") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://www.51xuecheng.cn") // .postLogoutRedirectUri("http://localhost:63070/login?logout") .scope("all") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .scope("message.read") .scope("message.write") .scope("read") .scope("write") .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(2)) // 设置访问令牌的有效期 .refreshTokenTimeToLive(Duration.ofDays(3)) // 设置刷新令牌的有效期 .reuseRefreshTokens(true) // 是否重用刷新令牌 .build()) .build(); return new InMemoryRegisteredClientRepository(registeredClient); }
用于对访问令牌进行签名的实例
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @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; }
用于解码签名访问令牌的JwtDecoder
实例
language-java 1 2 3 4 5 @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); }
用于配置Spring Authorization Server
的 AuthorizationServerSettings
实例
language-java 1 2 3 4 5 @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); }
这里可以设置各种端点的路径,默认路径点开builder()即可看到,如下
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static Builder builder() { return new Builder() .authorizationEndpoint("/oauth2/authorize") .deviceAuthorizationEndpoint("/oauth2/device_authorization") .deviceVerificationEndpoint("/oauth2/device_verification") .tokenEndpoint("/oauth2/token") .jwkSetEndpoint("/oauth2/jwks") .tokenRevocationEndpoint("/oauth2/revoke") .tokenIntrospectionEndpoint("/oauth2/introspect") .oidcClientRegistrationEndpoint("/connect/register") .oidcUserInfoEndpoint("/userinfo") .oidcLogoutEndpoint("/connect/logout"); }
这里我必须吐槽一下,qnmd /.well-known/jwks.json,浪费我一下午。获取公钥信息的端点现在已经替换成了/oauth2/jwks。
四、认证服务器测试 基本上跟着Getting Started
走就行。只不过端点的变动相较于Oauth2
很大,还有使用方法上不同。
在配置RegisteredClient
的时候,我们设置了三种GrantType
,这里只演示两种AUTHORIZATION_CODE
和CLIENT_CREDENTIALS
。
1. AUTHORIZATION_CODE(授权码模式) 1. 获取授权码 用浏览器打开以下网址,
language-txt 1 http://localhost:63070/auth/oauth2/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn
对应oauth2/authorize
端点,后面的参数和当时设置RegisteredClient
保持对应就行。response_type
一定是code
。 进入到登陆表单,输入lisi
- 456
登陆。
选择all,同意请求。
url
被重定向到http://www.51xuecheng.cn
,并携带一个code
,这就是授权码。
language-txt 1 http://www.51xuecheng.cn/?code=9AexK_KFH1m3GiNBKsc0FU2KkedM2h_6yR-aKF-wPnpQT5USKLTqoZiSkHC3GUvt-56_ky-E3Mv5LbMeH9uyd-S1UV6kfJO6znqAcCAF43Yo4ifxTAQ8opoPJTjLIRUC
2. 获取JWT 使用apifox
演示,postman
,idea-http
都可以。 向localhost:63070/auth
服务的/oauth2/token
端点发送Post
请求,同时需要携带认证信息。 认证信息可以如图所填的方法,也可以放到Header
中,具体做法是将客户端ID和客户端密码用冒号(:)连接成一个字符串,进行Base64
编码放入HTTP
请求的Authorization
头部中,前缀为Basic
。比如Authorization: Basic bXlDbGllbnRJZDpteUNsaWVudFNlY3JldA==
得到JWT
2. CLIENT_CREDENTIALS(客户端凭证模式) 不需要授权码,直接向localhost:63070/auth
服务的/oauth2/token
端点发送Post
请求,同时需要携带认证信息。
五、Gateway 至于gateway
基础搭建步骤和gateway
管理的若干微服务本文不做指导。
相较于auth
模块(也就是Authorization Server
),gateway
的角色是Resource Server
。
1. 引入依赖 language-xml 1 2 3 4 5 6 7 8 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
2. 添加白名单文件 在resource
下添加security-whitelist.properties
文件。 写入以下内容
language-properties 1 2 3 /auth/**=???? /content/open/**=?????????? /media/open/**=??????????
3. 全局过滤器 在全局过滤器中,加载白名单,然后对请求进行判断。
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 @Component @Slf4j public class GatewayAuthFilter implements GlobalFilter, Ordered { //白名单 private static List<String> whitelist = null; static { //加载白名单 try ( InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties"); ) { Properties properties = new Properties(); properties.load(resourceAsStream); Set<String> strings = properties.stringPropertyNames(); whitelist= new ArrayList<>(strings); } catch (Exception e) { log.error("加载/security-whitelist.properties出错:{}",e.getMessage()); e.printStackTrace(); } } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String requestUrl = exchange.getRequest().getPath().value(); log.info("请求={}",requestUrl); AntPathMatcher pathMatcher = new AntPathMatcher(); //白名单放行 for (String url : whitelist) { if (pathMatcher.match(url, requestUrl)) { return chain.filter(exchange); } } } private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) { ServerHttpResponse response = exchange.getResponse(); String jsonString = JSON.toJSONString(new RestErrorResponse(error)); byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8); DataBuffer buffer = response.bufferFactory().wrap(bits); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add("Content-Type", "application/json;charset=UTF-8"); return response.writeWith(Mono.just(buffer)); } @Override public int getOrder() { return 0; } }
4. 获取远程JWKS 在yml
配置中添加jwk-set-uri
属性。
language-yml 1 2 3 4 5 6 spring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://localhost:63070/auth/oauth2/jwks
新建配置类,自动注入JwtDecoder
。
language-java 1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class JwtDecoderConfig { @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri; @Bean public JwtDecoder jwtDecoderLocal() { return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); } }
5. 校验JWT 在全局过滤器中补全逻辑。
language-java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 @Component @Slf4j public class GatewayAuthFilter implements GlobalFilter, Ordered { @Lazy @Autowired private JwtDecoder jwtDecoderLocal; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String requestUrl = exchange.getRequest().getPath().value(); log.info("请求={}",requestUrl); AntPathMatcher pathMatcher = new AntPathMatcher(); //白名单放行 for (String url : whitelist) { if (pathMatcher.match(url, requestUrl)) { return chain.filter(exchange); } } //检查token是否存在 String token = getToken(exchange); log.info("token={}",token); if (StringUtils.isBlank(token)) { return buildReturnMono("没有携带Token,没有认证",exchange); } // return chain.filter(exchange); try { Jwt jwt = jwtDecoderLocal.decode(token); // 如果没有抛出异常,则表示JWT有效 // 此时,您可以根据需要进一步检查JWT的声明 log.info("token有效期至:{}", formatInstantTime(jwt.getExpiresAt())); return chain.filter(exchange); } catch (JwtValidationException e) { log.info("token验证失败:{}",e.getMessage()); return buildReturnMono("认证token无效",exchange); } } /** * 从请求头Authorization中获取token */ private String getToken(ServerWebExchange exchange) { String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization"); if (StringUtils.isBlank(tokenStr)) { return null; } String token = tokenStr.split(" ")[1]; if (StringUtils.isBlank(token)) { return null; } return token; } /** * 格式化Instant时间 * * @param expiresAt 在到期 * @return {@link String} */ public String formatInstantTime(Instant expiresAt) { // 将Instant转换为系统默认时区的LocalDateTime LocalDateTime dateTime = LocalDateTime.ofInstant(expiresAt, ZoneId.systemDefault()); // 定义日期时间的格式 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); // 格式化日期时间并打印 return dateTime.format(formatter); } }
6. 测试(如何携带JWT) 携带一个正确的JWT
向gateway
发送请求。 把JWT
写到Header
的Authorization
字段中,添加前缀Bearer
(用空格隔开),向gateway
微服务所在地址发送请求。
gateway日志输出。
六、后记 颁发JWT都归一个认证服务器管理,校验JWT都归Gateway管理,至于授权,则由各个微服务自己定义。耦合性低、性能较好。
关于授权,可以接着这篇文章。微服务OAuth 2.1认证授权Demo方案(Spring Security 6)