前言:
在上一章已经实现了SpringBoot单服务的鉴权,在导入SpringSecurity的相关依赖,以及使用JWT生成的accessToken和refreshToken能够实现不同Controller乃至同一Controller中不同接口的权限单独校验。上一章链接如下:
从零搭建微服务项目Pro(第6-1章——Spring Security+JWT实现用户鉴权访问与token刷新)_微服务springboot+security+jwt实现刷新token-CSDN博客https://blog.csdn.net/wlf2030/article/details/146316131?spm=1001.2014.3001.5501但在微服务架构中,如何实现各服务统一使用相同鉴权模块、如何权衡网关和鉴权模块的关系,如何确保Feign调用不被SpringSecurity拦截,如何使用对无状态的JWT进行控制,这些问题仍需要解决。
本章针对这些问题给出了解答分析以及代码示例。完整代码链接如下:
(该链接为一个笔者正在开发的微服务商城项目,会逐渐整合本专栏所有功能,欢迎Star)
wlf728050719/BitGoPlushttps://github.com/wlf728050719/BitGoPlus
以及本专栏会持续更新微服务项目,每一章的项目都会基于前一章项目进行功能的完善,欢迎小伙伴们关注!同时如果只是对单章感兴趣也不用从头看,只需下载前一章项目即可,每一章都会有前置项目准备部分,跟着操作就能实现上一章的最终效果,当然如果是一直跟着做可以直接跳过这一部分。专栏目录链接如下,其中Base篇为基础微服务搭建,Pro篇为复杂模块实现。
从零搭建微服务项目(全)-CSDN博客https://blog.csdn.net/wlf2030/article/details/145799620?spm=1001.2014.3001.5501
核心依赖:
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
</dependency>
权限实体:
package cn.bit.pojo.dto;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
@Getter
public class BitGoUser extends User {
private final UserBaseInfo userBaseInfo;
public BitGoUser(UserBaseInfo userBaseInfo, Collection<? extends GrantedAuthority> authorities) {
super(userBaseInfo.getUsername(), userBaseInfo.getPassword(), authorities);
this.userBaseInfo = userBaseInfo;
}
}
先定义整个项目的鉴权实体类,当有请求访问时,通过鉴权后会将这个实体类存储在整个服务的鉴权上下文中,在加上注解进行aop操作即可实现单接口权限控制。这里是直接继承spring.secutity定义好的user,user定义如下:
当然也可以不选择继承定义好的User,但必须实现UserDetails接口中的所有方法,UserDetails可以看作整个spring.security的核心。
即主要实现用户用户名,密码,权限,以及是否过期,是否上锁,是否启用,是否权限超时。
继续深入查看权限接口是如何定义的,其实会发现spring.security鉴权的底层实际是鉴是否有字符串。
实体初始化:
在定义好权限实体类后,我们需要给一个service用来初始化权限。具体代码如下:
package cn.bit.service.impl;
import cn.bit.constant.SecurityConstant;
import cn.bit.pojo.dto.BitGoAuthorization;
import cn.bit.pojo.dto.UserBaseInfo;
import cn.bit.service.BitGoUserService;
import cn.bit.exception.BizException;
import cn.bit.exception.SysException;
import cn.bit.client.UserClient;
import cn.bit.pojo.dto.BitGoUser;
import cn.bit.pojo.vo.R;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Set;
@Service("BitGoUserService")
@AllArgsConstructor
public class BitGoUserServiceFeignImpl implements BitGoUserService {
private final UserClient userClient;
@Override
public UserDetails loadUserByUsername(String username) {
return getBitGoUserFromRPC(username);
}
public BitGoUser getBitGoUserFromRPC(String username) {
// 获取用户基本信息
R<UserBaseInfo> userResponse = userClient.getInfoByUsername(username);
if (userResponse == null) {
throw new SysException("get response from user-service failed");
}
if (userResponse.getData() == null) {
throw new BizException("用户名不存在");
}
UserBaseInfo user = userResponse.getData();
// 获取用户角色信息
R<Set<BitGoAuthorization>> roleResponse = userClient.getBitGoAuthorizationByUserId(user.getUserId());
if (roleResponse == null) {
throw new SysException("get response from user-service failed");
}
// 构建BitGoUser对象
return new BitGoUser(user, roleResponse.getData());
}
@Override
public boolean checkUser(BitGoUser user, Long userId) {
if (user == null) {
return false;
}
return user.getUserBaseInfo().getUserId().equals(userId);
}
@Override
public boolean checkAdmin(BitGoUser user) {
return checkRoleAndTenantId(user, null, SecurityConstant.ROLE_ADMIN);
}
@Override
public boolean checkShopKeeper(BitGoUser user, Long tenantId) {
return checkRoleAndTenantId(user, tenantId, SecurityConstant.ROLE_SHOPKEEPER);
}
@Override
public boolean checkClerk(BitGoUser user, Long tenantId) {
return checkRoleAndTenantId(user, tenantId, SecurityConstant.ROLE_CLERK);
}
private boolean checkRoleAndTenantId(BitGoUser user, Long tenantId, String roleCode) {
if (user == null) {
return false;
}
// 获取用户的所有授权信息
Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
// 检查是否有匹配的角色
return authorities.stream()
.filter(auth -> auth instanceof BitGoAuthorization)
.map(auth -> (BitGoAuthorization) auth)
.anyMatch(auth -> {
// 1. 先检查角色是否匹配
boolean roleMatches = auth.getRoleCode().equals(roleCode);
// 2. 如果角色不匹配,直接返回 false
if (!roleMatches) {
return false;
}
// 3. 如果角色匹配,且不需要检查租户(tenantId == null),则直接返回 true
if (tenantId == null) {
return true;
}
// 4. 如果需要检查租户,则检查该角色的租户是否匹配
return tenantId.equals(auth.getTenantId());
});
}
}
这里先是定义了一个服务接口方便后续拓展,接口如下:
主要功能是加载用户,以及判断用户的身份是否符合要求。加载用户时远程调用user-service从数据库提取用户权限并装载实体类。主要核心是实现UserDetailService的方法。
实体类注入:
我们需要将实体类注入上下文中,方便每个接口进行鉴权。这里是在每次请求的过滤器进行注入:
package cn.bit.filter;
import cn.bit.constant.RedisKey;
import cn.bit.pojo.dto.BitGoUser;
import cn.bit.pojo.dto.InternalServiceAuthentication;
import cn.bit.constant.SecurityConstant;
import cn.bit.util.JwtUtil;
import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@AllArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private JwtUtil jwtUtil;
private UserDetailsService userDetailsService;
private RedisTemplate<String, Object> redisTemplate;
@SuppressWarnings("checkstyle:ReturnCount")
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader(SecurityConstant.HEADER_AUTHORIZATION);
final String sourceHeader = request.getHeader(SecurityConstant.HEADER_SOURCE);
// 处理内部服务Token
if (authorizationHeader != null && authorizationHeader.startsWith(SecurityConstant.TAG_INTERNAL)
&& sourceHeader != null && sourceHeader.startsWith(SecurityConstant.TAG_SERVICE)) {
String source = sourceHeader.substring(SecurityConstant.TAG_SERVICE.length());
String token = authorizationHeader.substring(SecurityConstant.TAG_INTERNAL.length());
if (jwtUtil.validateInternalToken(token, source)) {
Authentication auth = new InternalServiceAuthentication(source);
SecurityContextHolder.getContext().setAuthentication(auth);
chain.doFilter(request, response);
return;
}
}
// 处理外部请求Token
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith(SecurityConstant.TAG_BEARER)) {
jwt = authorizationHeader.substring(7);
username = jwtUtil.extractData(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
BitGoUser bitGoUser = (BitGoUser) userDetails;
String key = String.format(RedisKey.TOKEN_KEY_FORMAT, bitGoUser.getUsername());
String value = (String) redisTemplate.opsForValue().get(key);
// 与缓存中jwt不一致禁止访问
if (value == null || !value.equals(jwt)) {
chain.doFilter(request, response);
return;
}
// 用户被删除或冻结时禁止访问
if (bitGoUser.getUserBaseInfo().getLockFlag() != 0 || bitGoUser.getUserBaseInfo().getDelFlag() != 0) {
chain.doFilter(request, response);
return;
}
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}
即每次请求,从请求头中取出jwt并进行解密,然后将解密出的用户名通过之前定义的service完成权限实体的初始化并注入上下文中。
全局访问规则配置:
在对接口进行精细化权限控制前,可对每个服务做全局规则配置。
package cn.bit.config;
import cn.bit.constant.SecurityConstant;
import cn.bit.filter.JwtAuthenticationFilter;
import cn.bit.util.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MicroserviceSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
private final RedisTemplate<String, Object> redisTemplate;
// 推荐使用构造函数注入
public MicroserviceSecurityConfig(JwtUtil jwtUtil, UserDetailsService userDetailsService,
RedisTemplate<String, Object> redisTemplate) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.redisTemplate = redisTemplate;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话
.and()
// 将JWT过滤器添加到UsernamePasswordAuthenticationFilter之前
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class).authorizeRequests()
.antMatchers("/auth/**").permitAll().antMatchers("/user/open/**").permitAll().antMatchers("/api/**")
.hasRole(SecurityConstant.ROLE_INTERNAL_SERVICE)// 允许认证端点公开访问
.anyRequest().authenticated(); // 其他所有请求需要认证
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtUtil, userDetailsService, redisTemplate);
}
}
这里的config中即配置允许所有用户访问/auth路径下接口和/user/open接口,同时只允许有内部服务权限的用户访问/api接口。
单接口注解配置:
package cn.bit.annotation;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("@BitGoUserService.checkAdmin(authentication.principal)")
public @interface Admin {
}
定义注解Admin,当调用接口前会执行名称为BitGoService的checkAdmin方法,方法实参为上下文中的实体,这样当上下文中权限实体被判定为true时才允许通过。
同时每个接口还能随时从上下文中取出权限实体类进行操作。下面接口为只有管理员用户能够访问,且输出访问实体类的用户id的示例。
使用工具类如下:
package cn.bit.util;
import lombok.experimental.UtilityClass;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
/**
* 安全工具类
*
* @author L.cm
*/
@UtilityClass
public class SecurityUtils {
/**
* 获取Authentication
*/
public Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
/**
* 获取用户
*/
public User getUser(Authentication authentication) {
if (authentication == null || authentication.getPrincipal() == null) {
return null;
}
Object principal = authentication.getPrincipal();
if (principal instanceof User) {
return (User) principal;
}
return null;
}
/**
* 获取用户
*/
public User getUser() {
Authentication authentication = getAuthentication();
return getUser(authentication);
}
}
内部服务鉴权:
在添加鉴权后,会发现原有的feign调用也一并被拦截。这里可以同样为feign做配置。实现思路有三种,一种是使用固定的key,在jwt过滤器时提取到key后单独设置,但key需要单独配置,且泄露后会造成较大危害。一种是服务内部的api允许任何人访问,内部服务通过自定义注解,当请求头中不含有某个自定义header时视为非法访问,网关对所有原始请求清洗对应header,同时feign调用时添加上对应请求头。但当请求头header内容泄露以及服务端口泄露,可直接不经过网关访问从而造成攻击。一种是同样为feign调用添加jwt识别header,但设置jwt过期时间很短,这样即使jwt意外泄漏也不会造成过大危害,只是稍微影响服务间调用的性能。很明细第三种最为安全,只有当jwt密钥泄露,或持续抓取服务间调用请求内容并攻击(这两种情况无论哪种防护方法都无解)才会出现问题。具体配置如下:
package cn.bit.config;
import cn.bit.constant.SecurityConstant;
import cn.bit.util.JwtUtil;
import feign.RequestInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeignSecurityConfiguration {
@Bean
@ConditionalOnMissingBean
public RequestInterceptor requestInterceptor(JwtUtil jwtUtil) {
return template -> {
// 从配置获取服务名,而不是硬编码
String serviceName = template.feignTarget().name();
String token = jwtUtil.generateInternalToken(serviceName);
template.header(SecurityConstant.HEADER_AUTHORIZATION, SecurityConstant.TAG_INTERNAL + token);
template.header(SecurityConstant.HEADER_SOURCE, SecurityConstant.TAG_SERVICE + serviceName);
};
}
}
对应前面filter内容
以及全局配置
JWT密钥状态管理:
尽管jwt无状态管理能够极大减小服务器存储压力,但试想下面案例,当用户密码泄露后,他人使用密码获取token,用户修改密码试图减小损失,他人使用原有token仍然能够访问对应接口,即同一时间能有多个token对应同一用户并同时操作,这显然是存在问题的,因此需要引入缓存存储每次颁发的token,当用户的token与缓存中token不一致时,拒绝访问。
对应过滤器内容
对应登录内容:
各服务启用鉴权:
上述所有内容定义在common-security模块中
并将所有需要的bean导出给每个服务使用
各服务只需要在对应pom导入即可使用。
最后:
spring.security其实的整体思路很清晰,但一定要搞清楚其和网关的关系,一开始我误以为jwt过滤器设置在网关,对于每一个请求网关均提取token并调用auth服务获取user后存取在上下文中,之后各服务直接从网关给的上下文拿取实体类,但实际上我犯了一个很严重的错误,spring cloud的每个服务都是独立的上下文,存在网关中的上下文是不能传递给其他服务的,正确思路应该如下,网关只负载基本的header清洗以及路由转发,jwt过滤器设置在每个服务上,有n个服务,则项目总共有n个jwt过滤器,同时有n个全局访问规则配置在生效,但由于所有服务都装填同一个配置类导致看起来像是在给网关配置访问规则,各服务使用UserDetailService完成实体类初始化后注入自己的上下文,并通过注解完成权限控制。不知道上面解释能否解答你的问题,如果仍不明白,强烈建议git上面代码链接,并查看spring.security的源码。