Java|Spring Security + Spring gateway + Oauth2 + JWT 整合实战
背景:我想在微服务中实现注册/登陆/登出功能,并且让部分服务只能在用户已登录状态下被访问。
核心诉求点:
- 访问服务需要携带用户信息,同时也能设置白名单,针对特定的接口不需要携带用户信息也可访问。
- 非业务的不合法的请求统一在网关层拦截,其他服务无需处理(当然,与业务相关的请求需要在api层处理)。
系统架构图如下:
登陆请求:用户使用账号+密码,通过网关到 auth-service 账号密码校验,校验通过后返还 token 到客户端。
访问api服务:用户将获取的 token 放到请求 Header 中,首先通过网关到 auth-service 进行 token 校验,校验通过才可以访问其他服务。
基础原理
服务器如何认证请求?
发展史
最初,Web基本是静态资源,是一份可以通过网络来查看的“文档”,每个HTTP请求对于服务器来说都是一样的,不需要知道是“谁”来访问了。随着交互式Web的兴起,如在线购物网站、论坛,识别是哪一个用户进行了访问就变成了问题(例如我想要把某个商品加到我的购物车,那么服务器需要先知道我是谁,才能把商品加到对应的购物车中)。因为HTTP请求是无状态的,那么就需要在请求中附带一些标识,用来区分不同用户。
基于 Session 的鉴权
在客户端登陆后,服务器将数据保存在 session 库中,并返还一个 session_id 给客户端,客户端将这个 session_id 存储到 cookie 中,在之后的请求中都附带上这个 session_id ,这样服务器在响应请求时就可以根据 session_id 去 session 库中获取数据,从而识别出是哪个用户。
基于 Session 验证的问题:
- 每个用户在认证之后,都需要在服务端的 session 库中保存记录,随着用户增多,服务端的存储开销会越来越大。
- 如果是分布式的应用,且 session 库是在内存中的,那么用户的下一次请求只有在访问到同一台机器才能获取授权的资源。这种情况下,如何在多台机器之间进行 session 的同步就成了问题。
- 如果用户的 cookie 被截获,那么也就相当于用户信息被暴露,用户很容齐受到跨站请求伪造的攻击。
基于 Token 的鉴权
相比 session,token 不需要在服务端存储用户的认证信息或者会话信息。token 鉴权的流程大致如下:
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证,按照一定的规则组装并发送给用户一个 token
- 客户端存储 token,并在每次请求时附送上这个 token 值
- 服务端按照之前定义的规则验证 token 值,并返回数据
OAuth 2.0
OAuth是一个关于授权的开放网络标准,在全世界得到广泛应用。OAuth 2.0 的标准参考 RFC 6749。
OAuth在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer)。“客户端"不能直接登录"服务提供商”,只能登录授权层,以此将用户与客户端区分开来。“客户端"登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。
“客户端"登录授权层以后,“服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。
OAuth 2.0 的流程图如下:
OAuth 2.0 - Protocol Flow
+--------+ +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+
(A)用户打开客户端以后,客户端要求用户给予授权。
(B)用户同意给予客户端授权。
(C)客户端使用上一步获得的授权,向认证服务器申请令牌。
(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
(E)客户端使用令牌,向资源服务器申请获取资源。
(F)资源服务器确认令牌无误,同意向客户端开放资源。
其中,客户端的授权模式有4种:
- 授权码模式(authorization code)
- 简化模式(implicit)
- 密码模式(resource owner password credentials)
- 客户端模式(client credentials)
授权码模式是功能最完整、流程最严密的授权模式。但由于我的服务要求并不高,且在系统中客户端、认证服务器和资源服务器都是自身的服务器,对于用户来说不存在安全问题,所以采用了密码模式来实现。
密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。
在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。
密码模式的流程图如下:
Resource Owner Password Credentials Flow
+----------+
| Resource |
| Owner |
| |
+----------+
v
| Resource Owner
(A) Password Credentials
|
v
+---------+ +---------------+
| |>--(B)---- Resource Owner ------->| |
| | Password Credentials | Authorization |
| Client | | Server |
| |<--(C)---- Access Token ---------<| |
| | (w/ Optional Refresh Token) | |
+---------+ +---------------+
(A)用户向客户端提供用户名和密码。
(B)客户端将用户名和密码发给认证服务器,向后者请求令牌。
(C)认证服务器确认无误后,向客户端提供访问令牌。
JWT
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该 token 也可直接被用于认证,也可被加密。
构成
JWT是由三段信息构成的,将这三段信息文本用.
链接一起就构成了Jwt字符串。三段信息分别是:
-
第一部分,头部(header)。header 承载的信息包含:
- 声明类型
- 声明加密的算法
{ "alg": "RS256", "typ": "JWT" }
将 JSON 进行 base64 加密后得到 header。
-
第二部分,载荷(payload)。payload 是存放有效信息的地方。这些有效信息包含三个部分
-
标准中注册的声明(建议但不强制使用)
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间。
- nbf: 定义在什么时间之前,该jwt都是不可用的。
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
-
公共的声明。可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。
-
私有的声明。是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
{ "user_id": 1, "user_name": "teza", "scope": [ "all" ], "exp": 1647944576, "corp_id": 1, "authorities": [ "CORP_USER" ], "jti": "d3830999-4c2b-4e5d-9c44-7423a3344caa", "client_id": "client-app" }
将 JSON 进行 base64 加密后得到 payload。
-
-
第三部分,签证(signature)。签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret
需要 base64 加密后的 header 和 base64 加密后的 payload 使用
.
连接组成的字符串,然后通过 header 中声明的加密方式进行加盐secret
组合加密,最后构成 jwt 的第三部分。注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret,那就意味着客户端是可以自我签发jwt了。
Java 实战
本次项目使用 Spring Cloud 作为微服务架构,使用 Eureka 作为注册中心,Gateway 作为服务网关。示例里包含一定的业务逻辑代码,与本人在做的毕设相关,但不影响理解。
服务架构如下:
.
├── auth-service --port:10110
├── eureka-server --port:10099
├── gateway --port:10086
└── api-service --port:10001
认证服务 auth-service
引入依赖
<dependencies>
<!-- spring boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- eureka client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- spring cloud security security 注意:oauth2包中已包含 无需再引入 -->
<!-- <dependency>-->
<!-- <groupId>org.springframework.cloud</groupId>-->
<!-- <artifactId>spring-cloud-starter-security</artifactId>-->
<!-- </dependency>-->
<!-- spring cloud security oauth2 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
</dependencies>
生成 RSA 证书
使用keytool
生成RSA证书jwt.jks
,复制到resource
目录下。
keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
实现Spring Secutity相关接口
UserDetailsService
实现UserDetailsService
接口,用于根据用户名(账号)加载用户信息。
// UserDetailsServiceImpl.java
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AuthUserHelper authUserHelper;
/**
* 获取用户信息
* - 密码
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return authUserHelper.getUserByAccount(username);
}
}
// 其中由authUserHelper提取的方法为根据username获取user信息
// 获取的TravelSecurityUser类为实现了UserDetails类的自定义类,包含了项目中业务上需要用的一些额外信息
// 如果不需要自定义内容,使用Spring Security实现好的User类即可
// AuthUserHelper.java
@Component
public class AuthUserHelper {
@Autowired
private UserDAO userDAO;
@Autowired
private CorpDAO corpDAO;
@Autowired
private PasswordEncoder passwordEncoder;
public UserDetails getUserByAccount(String userAccount) throws UsernameNotFoundException {
// 1. 检查账号
// 注意这里数据库里存的密码不是明文,是已经加密过的了
// 如果密码不是加密后的,需要使用passwordEncoder加密一下(当然还是建议不存储用户的明文密码,存在严重安全隐患)
UserParam userParam = new UserParam();
userParam.createCriteria()
.andUserAccountEqualTo(userAccount);
List<UserDO> userDOS = userDAO.selectByParam(userParam);
if (CollectionUtils.isEmpty(userDOS)) {
// 用户名不存在
throw new UsernameNotFoundException(AuthMessageErrorEnum.USER_NOT_EXIST.getMessage());
}
// 2. 校验权限
UserDO userDO = userDOS.get(0);
List<GrantedAuthority> authorities = new ArrayList<>();
// 普通职工权限默认拥有
authorities.add(AuthorityRoleEnum.CORP_EMPLOYEE.getGrantedAuthority());
// 校验是否是企业主管理员
CorpParam corpParam = new CorpParam();
corpParam.createCriteria()
.andManagerUserIdEqualTo(userDO.getUserId())
.andCorpIdEqualToWhenPresent(userDO.getCorpId());
List<CorpDO> corpDOS = corpDAO.selectByParam(corpParam);
if (CollectionUtils.isNotEmpty(corpDOS)) {
authorities.add(AuthorityRoleEnum.CORP_MAIN_ADMIN.getGrantedAuthority());
}
return new TravelSecurityUser(
userDO.getCorpId(), userDO.getUserId(),
userDO.getUserAccount(), userDO.getUserPassword(), authorities);
}
}
AuthorizationServerConfigurerAdapter
实现AuthorizationServerConfigurerAdapter
接口,配置认证服务相关设置,需要配置加载用户信息的服务UserDetailsServiceImpl
及RSA的钥匙对KeyPair
。
// OAuth2AuthServerConfig
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenEnhancer jwtTokenEnhancer;
@Autowired
private UserDetailsServiceImpl userDetailsService;
//配置客户端应用的相关信息
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
super.configure(clients);
// 将客户端的信息写入内存中
clients.inMemory()
.withClient("client-app") //客户端ID
.secret(passwordEncoder.encode("123456")) //客户端密码
.scopes("all") //用户对该客户端可以申请的权限
.accessTokenValiditySeconds(3600) //令牌的有效期(单位为秒)
.refreshTokenValiditySeconds(86400) // refresh token有效期
.authorizedGrantTypes("password", "refresh_token"); //用户要访问该客户端的时候采取的授权类型,本项目采用密码模式。
}
//配置令牌管理器和令牌的存储方式
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
super.configure(endpoints);
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();
delegates.add(jwtTokenEnhancer); //添加自定义信息的增强器(实现了TokenEnhancer接口)
delegates.add(accessTokenConverter()); //jwt转换token的增强器
enhancerChain.setTokenEnhancers(delegates); //配置token内容增强器
endpoints.authenticationManager(authenticationManager) // 由 authenticationManager 来管理
.userDetailsService(userDetailsService) //配置加载用户信息的服务
.accessTokenConverter(accessTokenConverter())
.tokenEnhancer(enhancerChain);
}
//配置谁能来验 token (有一些请求连验 token 的资格都没有)
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
super.configure(security);
security.allowFormAuthenticationForClients(); // 允许以form表单形式提交 走ClientCredentialsTokenEndpointFilter
// 通过body的form表单中的client_id和client_secret未加密字段来校验
// 如果不允许 走BasicAuthenticationFilter
// 一般来说通过basic auth,是通过解析在header的Authorization加上Basic+client_id+client_secret转换的加密串来校验
// basic auth(虽然这个加密也相当于是明文的)
security.checkTokenAccess("isAuthenticated()");
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setKeyPair(keyPair());
return jwtAccessTokenConverter;
}
@Bean
public KeyPair keyPair() {
//从classpath下的证书中获取秘钥对
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "W.w215.+".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "生成rsa证书时的密码".toCharArray());
}
}
暴露公钥
由于我们的网关服务需要RSA的公钥来验证签名是否合法,所以认证服务需要有个接口把公钥暴露出来。
@RestController
public class KeyPairController {
@Autowired
private KeyPair keyPair;
@GetMapping("/rsa/publicKey")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
OAuth2WebSecurityConfig
配置 Spring Security,允许访问公钥接口。
@Configuration
@EnableWebSecurity //支持 WebSecurity
public class OAuth2WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.antMatchers("/rsa/publicKey").permitAll() // 允许访问公钥接口
.anyRequest().authenticated();
}
//配置如何构建 AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService) //获取用户详细信息(用户名、权限等)
.passwordEncoder(passwordEncoder); //密码加密器
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
验证
这里使用 Postman 进行测试,选择 Auth Type 为 Basic Auth(或者直接在body的form中写入client_id
和client_secret
字段),写上 username 和 password(也就是 OAuth2AuthServerConfig 里设置的在内存中的 client_id
和secret
)。
再设置好登陆的用户名和密码,以及 OAuth2AuthServerConfig 里配置好允许的认证类型(password)和请求的权限(all)。
可以看到服务端响应的请求如下:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJheWFuIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY0ODU0NDUwOCwiY29ycF9pZCI6MiwiYXV0aG9yaXRpZXMiOlsiQ09SUF9VU0VSIl0sImp0aSI6ImM0NDRiNzU3LTdhZmMtNGQ5OC1hNzEzLWJlYTkyYmQ1ZjVkNyIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.ZIeEzyk25ix5SL1lMXQhe6jC76y2t3NND-mpwl9QTLvpPYkO6jdpsDikTOh75E-UhJvSTaxS3jB3JrBt3tL50IjXrxDhNvpGlvIpF-cEL6mxSXZhF3ZOOfj1BHJHOVk3ppNVNqJkt4s_4kjg_a8U1uZlHg7-5eub7RFIuAW01R4u1oxZ3EtftTmdGqHw3PiL0OpNUH9q-Y-DT56vTPGeNdBTMS1CoJQRev5FX8jhOXFJfFsmTwVa7U_Cy_glKNRftUNX6trDzOARwG0JI23BMB2iCe8K6DKQXxOIlo57tMAkZBIPVrDwmnuElPYfmagcUp-0ejzmMD2mqH-CKX6vmg",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJheWFuIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImM0NDRiNzU3LTdhZmMtNGQ5OC1hNzEzLWJlYTkyYmQ1ZjVkNyIsImV4cCI6MTY0ODYyNzMwOCwiY29ycF9pZCI6MiwiYXV0aG9yaXRpZXMiOlsiQ09SUF9VU0VSIl0sImp0aSI6IjVmYjVmNjRiLTU0YTEtNDk2Zi1hOTU3LWVmODY5Y2I1M2YyMiIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.ippaehx2sKwqg2dvbjOELd_ab8pM-X8fki7DpvTsAoAHtAFBdsdm2r3A_lNbOCl9ZxwetzC6UFQA5tg0np8Y0cdwHkX_CuB2ldlPg_ro27cnPxEQJZ_eap8auWKxbT-IOA6-iaYW5Q0VPNCtNJdnAeeK6kXxTzctD82Dk_wJ3SBhBh7W6hwsoYQGmuqd89f_CiEgkLumXO2uQa3paC9v8eQEoiqRebRb7s1Mx3ZkzCWCkAto108Y7KEb40x4GT6HNQtuth30T9AcLSgHqO2--sYq14vL3A0YsO8Tx823C6T1Rp_-462OB0GF2HsIJiQpHidReB75TvDmAhiV4xB__Q",
"expires_in": 3599,
"scope": "all",
"user_id": 2,
"corp_id": 2,
"jti": "c444b757-7afc-4d98-a713-bea92bd5f5d7"
}
到 jwt 官网 jwt - debugger 使用 debugger 工具对access_token
进行验证:
可以看到 jwt.payload 区已经携带了 user_name
、scope
、authorities
、client_id
以及 token 有效时间等信息(这里的user_id
和corp_id
是我在 JwtTokenEnhancer 内容增强器里补充的 claim)。另外下面也可以用公钥去验证签名。
网关 gateway
引入依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>8.16</version>
</dependency>
<!-- eureka client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- 自定义元数据 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
配置应用信息
在application.yml
中添加相关配置,主要是路由规则的配置、Oauth2中RSA公钥的配置及路由白名单的配置。
server:
port: 10086 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
gateway:
globalcors:
add-to-simple-url-handler-mapping: true
cors-configurations:
'[/**]':
allowedOrigins: "*" # 允许哪些网站的跨域请求 allowedOrigins: “*” 允许所有网站
allowedMethods: # 允许的跨域请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带cookie
maxAge: 360000 # 这次跨域检测的有效期
routes: # 网关路由配置
- id: api-service # 路由id,自定义,只要唯一即可
uri: lb://member-service # 路由的目标地址 lb就是负载均衡,后面跟服务名称
predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
- Path=/member/** # 按照路径匹配,只要以/member开头就符合要求
- id: auth-service
uri: lb://auth-service
predicates:
- Path=/auth/** # 按照路径匹配,只要以/auth开头就符合要求
filters:
- StripPrefix=1 # 在将请求发送到下游之前从请求中剥离的路径个数
discovery:
locator:
enabled: true # 开启从注册中心动态创建路由的功能
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: 'http://localhost:10110/rsa/publicKey' #配置RSA的公钥访问地址
# eureka 配置
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10099/eureka
# 自定义配置
secure:
ignore:
urls: #配置白名单路径
- "/actuator/**"
- "/auth/oauth/token"
安全配置
ResourceServerConfig
对网关服务进行配置安全配置,由于Gateway使用的是WebFlux
,所以需要使用@EnableWebFluxSecurity
注解开启。
/**
* 资源服务器配置
*
* @author github @TezaLeMon
* @date 2022/3/21
*/
@EnableWebFluxSecurity
public class ResourceServerConfig {
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Autowired
private AuthorizationManager authorizationManager;
@Autowired
private IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
//自定义处理JWT请求头过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
//对白名单路径,直接移除JWT请求头
http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
.pathMatchers(HttpMethod.OPTIONS).permitAll()
.pathMatchers(ignoreUrlsConfig.getUrls().toArray(new String[]{})).permitAll() //放行白名单配置
.anyExchange().access(authorizationManager) //鉴权管理器
.and().exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler) //处理未授权
.authenticationEntryPoint(restAuthenticationEntryPoint) //处理未认证
.and().csrf().disable();
return http.build();
}
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
AuthorizationManager 自定义鉴权管理器
在认证服务器我已经默认至少给用户CORP_EMPLOYEE
角色,所以这里鉴权时相当于只要是认证用户就会放行(其实已经属于多余操作了),如果自己有相关的需求可以在此基础上修改(比如设置某些 api 只能被特定的角色访问)。
/**
* 鉴权管理器,用于判断是否有资源的访问权限
*
* @author github @TezaLeMon
* @date 2022/3/22
*/
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
// 获取当前路径可访问角色列表 todo 目前配置为网关层允许所有role通过,在应用层进行权限校验
// URI uri = authorizationContext.getExchange().getRequest().getURI();
List<String> authorities = Arrays.stream(AuthorityRoleEnum.values()).map(a -> AuthConstant.AUTHORITY_PREFIX + a.getRoleCode()).collect(Collectors.toList());
//认证通过且角色匹配的用户可访问当前路径
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(authorities::contains)
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
AuthGlobalFilter 全局过滤器
这个过滤器不是必须的,主要是用于在鉴权通过后将 JWT 令牌中的用户信息解析出来,然后存入请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户信息。
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(token)) {
return chain.filter(exchange);
}
try {
//从token中解析用户信息并设置到Header中去
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
log.info("ltd.teza.gateway.filter.AuthGlobalFilter.filter() user:{}", userStr);
ServerHttpRequest request = exchange.getRequest().mutate().header("user", userStr).build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
log.error("ltd.teza.gateway.filter.AuthGlobalFilter JWSObject.parse exception", e);
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
验证
这里将地址改为网关的地址,并且加上前缀 /auth(别忘了在设置路由时设置了以/auth开头的请求转发到认证服务器,并且在转发前剥离一段路径)。可以看到我们的请求成功由网关转发到认证服务器了。
应用层服务 api-service
引入依赖
应用层不做鉴权,写正常的api接口即可。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
获取鉴权后网关层写到header的信息
UserSession
用于存储携带过来的信息。
@Component
public class UserSession {
private static final ThreadLocal<Long> corpId = new ThreadLocal<>();
private static final ThreadLocal<Long> userId = new ThreadLocal<>();
public static void setUser(Long corpId, Long userId) {
UserSession.corpId.set(corpId);
UserSession.userId.set(userId);
}
public static void clear() {
UserSession.corpId.remove();
UserSession.userId.remove();
}
public static Long getCorpId() {
return corpId.get();
}
public static Long getUserId() {
return userId.get();
}
}
AuthFilter
使用 filter 将 header 中的信息提取出来。
主要流程:
graph TB;
A([AuthFilter])
-->B(获取request.header)
-->C("获取header中的user字段(JSON形式)")
-->D("JSON.parse(user),得到由网关写入的内容(即user_id和corp_id)")
-->E(将得到的信息写入UserSession)
-->F("filter链继续执行<br>filterChain.doFilter(servletRequest, servletResponse)")
-->G(清空UserSession里的信息)
代码如下:
@Slf4j
@Component
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//从Header中获取用户信息
try {
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String userStr = null;
if (servletRequestAttributes != null) {
userStr = servletRequestAttributes.getRequest().getHeader("user");
}
JSONObject userJsonObject = JSONObject.parseObject(userStr);
// 获取用户信息
UserSession.setUser(userJsonObject.getLong("corp_id"), userJsonObject.getLong("user_id"));
} catch (Exception e) {
log.error("ltd.teza.member.filter.AuthFilter 获取用户信息异常,可能是未登陆导致。", e);
} finally {
filterChain.doFilter(servletRequest, servletResponse);
// 清除用户信息
UserSession.clear();
}
}
}
实现测试controller
@RestController
@RequestMapping("/member")
@Slf4j
public class MemberHelloController implements MemberHelloClient {
@GetMapping("hello")
public ServiceResult<String> hello() {
log.info("member-service --> ltd.teza.controller.HelloController.hello --> test log.info");
// log.error("member-service --> ltd.teza.controller.HelloController.hello --> test log.error");
ServiceResult<String> result = new ServiceResult<>(true);
result.setModule("hello! this is member-service!~");
return result;
}
@GetMapping("getUser")
public String getUser() {
Long userId = UserSession.getUserId();
Long corpId = UserSession.getCorpId();
return "corpId=" + corpId + "\nuserId=" + userId;
}
}
验证
通过网关访问来调取接口,在 header 中附加上之前获取的 access_token(记得前缀加上Bearer
)。可以看到网关没有拦截请求,且在api层也获取到了用户信息。
接下来试试不携带 token 来访问。可以看到在网关就直接拒绝了请求。
PS:当 access_token 过期时,我们可以用 refresh_token 来获取新的 access_token(正常来说 refresh_token 的有效期要比 access_token 长的多),这样就可以减少用户需要重新输入账号密码来登陆的情况,这里就不再赘述了。
参考资料: