此篇文章为spring security系列的第一篇,着重讲解如何通过spring security完成企业级项目的权限控制,以及采用Redis的方式控制JWT的失效。
1. 什么是RBAC
RBAC(Role-Based Access Control )基于角色的权限控制,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。
2. JWT 和 Spring Security
spring security 授权主要分为两种,一种是security内部负责维护登录用户的session,一种则是采用JWT的方式,不管理session。关于JWT 和 Security的详细资料请小伙伴们自行查阅(相关网址推荐:http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html)
此处就不在赘述,好了下面开始正文吧。
3.1 导入依赖
<!-- spring security 和 jwt -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
3. security 核心配置类:WebSecurityConfig
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(CustomConfig.class)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomConfig customConfig;
@Autowired
private SecurityUserService securityUserService;
@Autowired
@Qualifier("securityAccessDeniedHandler")
private AccessDeniedHandler securityAccessDeniedHandler;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(securityUserService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
// 关闭 CSRF
.and().csrf().disable()
// 登录行为由自己实现,参考 LoginController#login
.formLogin().disable()
.httpBasic().disable()
// 认证请求
.authorizeRequests()
// 所有请求都需要登录访问
.anyRequest()
.authenticated()
// RBAC 动态 url 认证
.anyRequest()
.access("@rbacAuthorityService.hasPermission(request,authentication)")
// 登出行为由自己实现,参考 LoginController#logout
.and().logout().disable()
// 异常处理
.exceptionHandling().accessDeniedHandler(securityAccessDeniedHandler);
// Session 管理
http.sessionManagement()
// 因为使用了JWT,所以这里不管理Session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 添加自定义 JWT 过滤器
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
/**
* 放行所有不需要登录就可以访问的请求,参见 AuthController
* 也可以在 {@link #configure(HttpSecurity)} 中配置
* {@code http.authorizeRequests().antMatchers("/api/auth/**").permitAll()}
*/
@Override
public void configure(WebSecurity web) {
WebSecurity and = web.ignoring().and();
// 忽略 GET
customConfig.getIgnores().getGet().forEach(url -> and.ignoring().antMatchers(HttpMethod.GET, url));
// 忽略 POST
customConfig.getIgnores().getPost().forEach(url -> and.ignoring().antMatchers(HttpMethod.POST, url));
// 忽略 DELETE
customConfig.getIgnores().getDelete().forEach(url -> and.ignoring().antMatchers(HttpMethod.DELETE, url));
// 忽略 PUT
customConfig.getIgnores().getPut().forEach(url -> and.ignoring().antMatchers(HttpMethod.PUT, url));
// 忽略 HEAD
customConfig.getIgnores().getHead().forEach(url -> and.ignoring().antMatchers(HttpMethod.HEAD, url));
// 忽略 PATCH
customConfig.getIgnores().getPatch().forEach(url -> and.ignoring().antMatchers(HttpMethod.PATCH, url));
// 忽略 OPTIONS
customConfig.getIgnores().getOptions().forEach(url -> and.ignoring().antMatchers(HttpMethod.OPTIONS, url));
// 忽略 TRACE
customConfig.getIgnores().getTrace().forEach(url -> and.ignoring().antMatchers(HttpMethod.TRACE, url));
// 按照请求格式忽略
customConfig.getIgnores().getPattern().forEach(url -> and.ignoring().antMatchers(url));
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 设置加密方式
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
1. AuthenticationManagerBuilder auth
主要设置了security的加密方式,(BCryptPasswordEncoder
也是目前比较流行安全的一种加密方式,它比MD5效率更高),userDetailsService
则负责对用户名、密码的校验和授权。
2. HttpSecurity http
主要是对security核心过滤器链的配置,可配置登录、登出及异常等处理器,因为我们采用的是JWT的方式,因此禁用了security提供的登录和登出,配置了JWT的过滤器,以及RBAC校验的方式。
3. WebSecurity web
主要负责配置一些security放行的路径,文中通过customConfig读取在配置文件中设置的放行的URL。
4. 配置JWT 的认证过滤器JwtAuthenticationFilter
/**
* @author lirong
* @ClassName: JwtAuthenticationFilter
* @Description: Jwt 认证过滤器
* @date 2019-07-12 9:50
*/
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private SecurityUserService userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private CustomConfig customConfig;
@Autowired
private IApplicationConfig applicationConfig;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 是否为放行的请求
if (checkIgnores(request)) {
chain.doFilter(request, response);
return;
}
String jwt = jwtUtil.getJwtFromRequest(request);
if (StrUtil.isNotBlank(jwt)) {
try {
String username = jwtUtil.getUsernameFromJWT(jwt, false);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (CustomException e) {
ResponseUtils.renderJson(request, response, e, applicationConfig.getOrigins());
}
} else {
ResponseUtils.renderJson(request, response, ResultCode.UNAUTHORIZED, null, applicationConfig.getOrigins());
}
}
/**
* 请求是否不需要进行权限拦截
* @param request 当前请求
* @return true - 忽略,false - 不忽略
*/
private boolean checkIgnores(HttpServletRequest request) {
String method = request.getMethod();
HttpMethod httpMethod = HttpMethod.resolve(method);
if (ObjectUtil.isNull(httpMethod)) {
httpMethod = HttpMethod.GET;
}
Set<String> ignores = Sets.newHashSet();
switch (httpMethod) {
case GET:
ignores.addAll(customConfig.getIgnores()
.getGet());
break;
case PUT:
ignores.addAll(customConfig.getIgnores()
.getPut());
break;
case HEAD:
ignores.addAll(customConfig.getIgnores()
.getHead());
break;
case POST:
ignores.addAll(customConfig.getIgnores()
.getPost());
break;
case PATCH:
ignores.addAll(customConfig.getIgnores()
.getPatch());
break;
case TRACE:
ignores.addAll(customConfig.getIgnores()
.getTrace());
break;
case DELETE:
ignores.addAll(customConfig.getIgnores()
.getDelete());
break;
case OPTIONS:
ignores.addAll(customConfig.getIgnores()
.getOptions());
break;
default:
break;
}
ignores.addAll(customConfig.getIgnores()
.getPattern());
if (CollUtil.isNotEmpty(ignores)) {
for (String ignore : ignores) {
AntPathRequestMatcher matcher = new AntPathRequestMatcher(ignore, method);
if (matcher.matches(request)) {
return true;
}
}
}
return false;
}
}
此过滤器会拦截访问系统的所有的请求,因此需要放行所有被忽略的URL,包括登录和登出,并将校验通过的JWT的用户信息封装为authentication。
5. RBAC权限匹配器
/**
* @author lirong
* @ClassName: JwtAuthenticationFilter
* @Description: Jwt 认证过滤器
* @date 2019-07-12 9:50
*/
@Slf4j
@Component
public class RbacAuthorityService {
@Autowired
private SecurityUserService userDetails;
@Autowired
private RequestMappingHandlerMapping mapping;
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
checkRequest(request);
Object userInfo = authentication.getPrincipal();
boolean hasPermission = false;
if (userInfo instanceof UserDetails) {
SecurityUser principal = (SecurityUser) userInfo;
SecurityUser userDTO = (SecurityUser) this.userDetails.loadUserByUsername(principal.getUsername());
//获取资源,前后端分离,所以过滤页面权限,只保留按钮权限
List<MenuRight> btnPerms = userDTO.getMenus().stream()
// 过滤页面权限
.filter(menuRight -> menuRight.getGrades() >= 3)
// 过滤 URL 为空
.filter(menuRight -> StrUtil.isNotBlank(menuRight.getUrl()))
// 过滤 METHOD 为空
.collect(Collectors.toList());
for (MenuRight btnPerm : btnPerms) {
AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(btnPerm.getUrl(), btnPerm.getMethod());
if (antPathMatcher.matches(request)) {
hasPermission = true;
break;
}
}
return hasPermission;
} else {
return false;
}
}
/**
* 校验请求是否存在
*
* @param request 请求
*/
private void checkRequest(HttpServletRequest request) {
// 获取当前 request 的方法
String currentMethod = request.getMethod();
Multimap<String, String> urlMapping = allUrlMapping();
for (String uri : urlMapping.keySet()) {
// 通过 AntPathRequestMatcher 匹配 url
// 可以通过 2 种方式创建 AntPathRequestMatcher
// 1:new AntPathRequestMatcher(uri,method) 这种方式可以直接判断方法是否匹配,因为这里我们把 方法不匹配 自定义抛出,所以,我们使用第2种方式创建
// 2:new AntPathRequestMatcher(uri) 这种方式不校验请求方法,只校验请求路径
AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(uri);
if (antPathMatcher.matches(request)) {
if (!urlMapping.get(uri)
.contains(currentMethod)) {
throw new CustomException(ResultCode.HTTP_BAD_METHOD);
} else {
return;
}
}
}
throw new CustomException(ResultCode.REQUEST_NOT_FOUND);
}
/**
* 获取 所有URL Mapping,返回格式为{"/test":["GET","POST"],"/sys":["GET","DELETE"]}
*
* @return {@link ArrayListMultimap} 格式的 URL Mapping
*/
private Multimap<String, String> allUrlMapping() {
Multimap<String, String> urlMapping = ArrayListMultimap.create();
// 获取url与类和方法的对应信息
Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();
handlerMethods.forEach((k, v) -> {
// 获取当前 key 下的获取所有URL
Set<String> url = k.getPatternsCondition()
.getPatterns();
RequestMethodsRequestCondition method = k.getMethodsCondition();
// 为每个URL添加所有的请求方法
url.forEach(s -> urlMapping.putAll(s, method.getMethods()
.stream()
.map(Enum::toString)
.collect(Collectors.toList())));
});
return urlMapping;
}
}
此方法看似很多,实则只做了一件事,就是把页面请求的URL和用户拥有的所有权限资源(URL)进行匹配。
6. UserDetailsService
查询数据库用户信息
/**
* @author lirong
* @Date 2019-7-14 22:46:54
* @Desc 从数据库查询用户数据
*/
@Component("securityUserService")
public class SecurityUserService implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Resource
private RoleMapper roleMapper;
@Resource
private MenuRightMapper menuRightMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDTO userDTO = userMapper.getRolesByUsername(username);
// 默认用户ID为1的为管理员
if (null != userDTO){
if(1L == userDTO.getId()) {
this.getAdminPermission(userDTO);
}
SecurityUser securityUser = new SecurityUser(LoginUserDTO.user2LoginUserDTO(userDTO));
return securityUser;
} else {
throw new UsernameNotFoundException(username + " 用户不存在!");
}
}
/**
* 为管理员赋所有权限
* @param userDTO
* @return
*/
private UserDTO getAdminPermission(UserDTO userDTO) {
List<Role> roles = roleMapper.selectAll();
List<MenuRight> menuRights = menuRightMapper.selectAll();
userDTO.setRoles(roles);
userDTO.setMenus(menuRights);
return userDTO;
}
}
7. JWT的刷新和登录用户的注销
众所周知,JWT是无状态的,服务端无法通过解析JWT知道用户是否提前注销,因此借助了Redis的过期机制,来达到通知用户退出的目的。创建JWT时,会将生成的JWT以用户名为前缀存入Redis,退出时,清除Redis中此用户名的JWT,每次访问时解析JWT并判断Redis中是否还存在此用户名的JWT,若不存在,则表示此用户已注销。
JWT的续签,此处采用的是refresh_token的形式,及登录的时候创建两个JWT,一个token,一个refresh_token,refresh_token的过期时间设置比较长,token失效后,前端可调用refresh_token的接口来刷新来达到续签的目的。
/**
* jwt工具类
* @author lirong
* @date 2018-9-26
*/
@EnableConfigurationProperties(JwtConfig.class)
@Configuration
@Slf4j
public class JwtUtil {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private JwtConfig jwtConfig;
/**
* 创建JWT
*
* @param authentication 用户认证信息
* @param rememberMe 记住我
* @return JWT
*/
public String createJWT(Authentication authentication, Boolean rememberMe, Boolean isRefresh) {
SecurityUser user = (SecurityUser) authentication.getPrincipal();
return createJWT(isRefresh, rememberMe, user.getId(), user.getUsername(), user.getRoles(), user.getMenus(), user.getAuthorities());
}
/**
* 创建JWT
*
* @param id 用户id
* @param subject 用户名
* @param roles 用户角色
* @param authorities 用户权限
* @return JWT
*/
public String createJWT(Boolean isRefresh,
Boolean rememberMe,
Long id,
String subject,
List<Role> roles,
List<MenuRight> menus,
Collection<? extends GrantedAuthority> authorities) {
Date now = new Date();
JwtBuilder builder = Jwts.builder()
.setId(id.toString())
.setSubject(subject)
.setIssuedAt(now)
.signWith(SignatureAlgorithm.HS256, jwtConfig.getSecret())
.claim("roles", roles)
// .claim("perms", menus)
.claim("authorities", authorities);
// 设置过期时间
Long ttl = rememberMe ? jwtConfig.getRemember() : jwtConfig.getTtl();
String redisKey;
if (isRefresh){
ttl *= 3;
redisKey = Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX + subject;
}else{
redisKey = Constant.REDIS_JWT_TOKEN_KEY_PREFIX + subject;
}
if (ttl > 0) {
builder.setExpiration(DateUtil.offsetMillisecond(now, ttl.intValue()));
}
String jwt = builder.compact();
// 将生成的JWT保存至Redis
redisTemplate.opsForValue().set(redisKey, jwt, ttl, TimeUnit.MILLISECONDS);
return jwt;
}
/**
* 解析JWT
*
* @param jwt JWT
* @return {@link Claims}
*/
public Claims parseJWT(String jwt, Boolean isRefresh) {
try {
Claims claims = Jwts.parser()
.setSigningKey(jwtConfig.getSecret())
.parseClaimsJws(jwt)
.getBody();
String username = claims.getSubject();
String redisKey = (isRefresh ? Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX : Constant.REDIS_JWT_TOKEN_KEY_PREFIX)
+ username;
// 校验redis中的JWT是否存在
Long expire = redisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS);
if (Objects.isNull(expire) || expire <= 0) {
throw new CustomException(ResultCode.TOKEN_EXPIRED);
}
// 校验redis中的JWT是否与当前的一致,不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期
String redisToken = (String) redisTemplate.opsForValue().get(redisKey);
if (!StrUtil.equals(jwt, redisToken)) {
throw new CustomException(ResultCode.TOKEN_OUT_OF_CTRL);
}
return claims;
} catch (ExpiredJwtException e) {
log.error("Token 已过期");
throw new CustomException(ResultCode.TOKEN_EXPIRED);
} catch (UnsupportedJwtException e) {
log.error("不支持的 Token");
throw new CustomException(ResultCode.TOKEN_PARSE_ERROR);
} catch (MalformedJwtException e) {
log.error("Token 无效");
throw new CustomException(ResultCode.TOKEN_PARSE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Token 参数不存在");
throw new CustomException(ResultCode.TOKEN_PARSE_ERROR);
}
}
/**
* 设置JWT过期
*
* @param request 请求
*/
public void invalidateJWT(HttpServletRequest request) {
String jwt = getJwtFromRequest(request);
String username = getUsernameFromJWT(jwt, false);
// 从redis中清除JWT
redisTemplate.delete(Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX + username);
redisTemplate.delete(Constant.REDIS_JWT_TOKEN_KEY_PREFIX + username);
}
/**
* 从 request 的 header 中获取 JWT
*
* @param request 请求
* @return JWT
*/
public String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
/**
* 根据 jwt 获取用户名
*
* @param jwt JWT
* @return 用户名
*/
public String getUsernameFromJWT(String jwt, Boolean isRefresh) {
Claims claims = parseJWT(jwt, isRefresh);
return claims.getSubject();
}
public Map<String, String> refreshJWT(String token) {
Claims claims = parseJWT(token, true);
// 获取签发时间
Date lastTime = claims.getExpiration();
// 1. 判断refreshToken是否过期
if (!new Date().before(lastTime)){
throw new CustomException(ResultCode.TOKEN_EXPIRED);
}
// 2. 在redis中删除之前的token和refreshToken
String username = claims.getSubject();
// redisTemplate.delete(Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX + username);
// redisTemplate.delete(Constant.REDIS_JWT_TOKEN_KEY_PREFIX + username);
// 3. 创建新的token和refreshToken并存入redis
String jwtToken = createJWT(false, false, Long.parseLong(claims.getId()), username,
(List<Role>) claims.get("roles"), null, (Collection<? extends GrantedAuthority>) claims.get("authorities"));
String refreshJwtToken = createJWT(true, false, Long.parseLong(claims.getId()), username,
(List<Role>) claims.get("roles"), null, (Collection<? extends GrantedAuthority>) claims.get("authorities"));
Map<String, String> map = new HashMap<>();
map.put("token", jwtToken);
map.put("refreshToken", refreshJwtToken);
return map;
}
/**
*
* 功能:生成 jwt token<br/>
* @param name 实例名
* @param param 需要保存的参数
* @param secret 秘钥
* @param expirationtime 过期时间(5分钟 5*60*1000)
* @return
*
*/
public static String sign(String name, Map<String,Object> param, String secret, Long expirationtime){
String JWT = Jwts.builder()
.setClaims(param)
.setSubject(name)
.setExpiration(new Date(System.currentTimeMillis() + expirationtime))
.signWith(SignatureAlgorithm.HS256,secret)
.compact();
return JWT;
}
/**
*
* 功能:解密 jwt<br/>
* @param JWT token字符串
* @param secret 秘钥
* @return
* @exception
*
*/
public static Claims verify(String JWT, String secret){
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(JWT)
.getBody();
return claims;
}
public static Object getValueFromToken(String jwt,String key, String secret){
return verify(jwt, secret).get(key);
}
}
登录和登出的方法
/**
* @author lirong
* @ClassName: LoginController
* @Description: 登录Controller
* @date 2019-07-12 9:31
*/
@Slf4j
@RestController
@RequestMapping("/")
public class LoginController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
/**
* 登录
*/
@PostMapping("/login")
public RestResult login(@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false, defaultValue = "false") Boolean rememberMe,
HttpServletRequest request,
HttpServletResponse response) {
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
String jwt = jwtUtil.createJWT(authentication, rememberMe, false);
String jwt_refresh = jwtUtil.createJWT(authentication, rememberMe, true);
Map<String, String> map = new HashMap<>();
map.put("token", jwt);
map.put("refreshToken", jwt_refresh);
CookieUtils.setCookie(response, "localhost", jwt);
return ResultGenerator.genSuccessResult().setMessage("登录成功").setData(map);
}
/**
* 退出
* @param request
* @return
*/
@PostMapping("/logout")
public RestResult logout(HttpServletRequest request) {
try {
// 设置JWT过期
jwtUtil.invalidateJWT(request);
} catch (CustomException e) {
throw new CustomException(ResultCode.UNAUTHORIZED);
}
return ResultGenerator.genSuccessResult().setMessage("退出成功");
}
/**
* 刷新过期的token
* @param refreshToken
* @return
*/
@PostMapping("/refresh/token")
public RestResult refreshToken(String refreshToken) {
Map<String, String> map;
try {
// 刷新
map = jwtUtil.refreshJWT(refreshToken);
} catch (CustomException e) {
throw new CustomException(ResultCode.TOKEN_EXPIRED);
}
return ResultGenerator.genSuccessResult().setMessage("token刷新成功").setData(map);
}
}
7. 效果测试
8. 数据库和源码
上面只是项目的部分核心代码,完整代码和数据库已托管到Github上,请访问:https://github.com/Janche/spring-security-rbac-jwt.git
自行下载,觉得有用的话,记得star哦,有什么问题欢迎大家通过issues或者邮件进行交流。