赞
踩
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校验。
若干微服务。
本文的关键在于以下几点:
这里是官方文档https://spring.io/projects/spring-authorization-server#learn
基本上跟着Getting Started
写完就可以。
新建一个数据库xc_users
。
然后执行jar
里自带的三个sql
。
这一步官方并没有给出,大概因为可以使用内存存储,在简单demo省去了持久化。不建立数据库可能也是可行的,我没试过。
新建一个auth
模块,作为认证服务器。
<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>
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
@Configuration
@EnableWebSecurity
public class AuthServerSecurityConfig {}
里面包含诸多内容,有来自Spring Security
的,也有来自的Spring Authorization Server
的。
UserDetailsService
的实例,用于检索用户进行身份验证。 @Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User
.withUsername("lisi")
.password("456")
.roles("read")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
public PasswordEncoder passwordEncoder() {
// 密码为明文方式
return NoOpPasswordEncoder.getInstance();
// 或使用 BCryptPasswordEncoder
// return new BCryptPasswordEncoder();
}
Spring Security
过滤器链@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
过滤器链。@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(); }
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
// 此处可以添加自定义逻辑来提取JWT中的权限等信息
// jwtConverter.setJwtGrantedAuthoritiesConverter(...);
return jwtConverter;
}
RegisteredClientRepository
实例@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); }
@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
实例 @Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
Spring Authorization Server
的 AuthorizationServerSettings
实例 @Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
这里可以设置各种端点的路径,默认路径点开builder()即可看到,如下
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
。
用浏览器打开以下网址,
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
,这就是授权码。
http://www.51xuecheng.cn/?code=9AexK_KFH1m3GiNBKsc0FU2KkedM2h_6yR-aKF-wPnpQT5USKLTqoZiSkHC3GUvt-56_ky-E3Mv5LbMeH9uyd-S1UV6kfJO6znqAcCAF43Yo4ifxTAQ8opoPJTjLIRUC
使用apifox
演示,postman
,idea-http
都可以。
向localhost:63070/auth
服务的/oauth2/token
端点发送Post
请求,同时需要携带认证信息。
认证信息可以如图所填的方法,也可以放到Header
中,具体做法是将客户端ID和客户端密码用冒号(:)连接成一个字符串,进行Base64
编码放入HTTP
请求的Authorization
头部中,前缀为Basic
。比如
Authorization: Basic bXlDbGllbnRJZDpteUNsaWVudFNlY3JldA==
得到JWT
不需要授权码,直接向localhost:63070/auth
服务的/oauth2/token
端点发送Post
请求,同时需要携带认证信息。
至于gateway
基础搭建步骤和gateway
管理的若干微服务本文不做指导。
相较于auth
模块(也就是Authorization Server
),gateway
的角色是Resource Server
。
<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>
在resource
下添加security-whitelist.properties
文件。
写入以下内容
/auth/**=????
/content/open/**=??????????
/media/open/**=??????????
在全局过滤器中,加载白名单,然后对请求进行判断。
@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; } }
在yml
配置中添加jwk-set-uri
属性。
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:63070/auth/oauth2/jwks
新建配置类,自动注入JwtDecoder
。
@Configuration
public class JwtDecoderConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
String jwkSetUri;
@Bean
public JwtDecoder jwtDecoderLocal() {
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
}
在全局过滤器中补全逻辑。
@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); } }
携带一个正确的JWT
向gateway
发送请求。
把JWT
写到Header
的Authorization
字段中,添加前缀Bearer
(用空格隔开),向gateway
微服务所在地址发送请求。
gateway日志输出。
颁发JWT都归一个认证服务器管理,校验JWT都归Gateway管理,至于授权,则由各个微服务自己定义。耦合性低、性能较好。
关于授权,可以接着这篇文章。
微服务OAuth 2.1认证授权Demo方案(Spring Security 6)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。