# spring-boot-demo-rbac-security
> 此 demo 主要演示了 Spring Boot 项目如何集成 Spring Security 完成权限拦截操作。本 demo 为基于**前后端分离**的后端权限管理部分,不同于其他博客里使用的模板技术,希望对大家有所帮助。
## 1. 主要功能
- [x] 基于 `RBAC` 权限模型设计,详情参考数据库表结构设计 [`security.sql`](./sql/security.sql)
- [x] 支持**动态权限管理**,详情参考 [`RbacAuthorityService.java`](./src/main/java/com/xkcoding/rbac/security/config/RbacAuthorityService.java)
- [x] **登录 / 登出**部分均使用自定义 Controller 实现,未使用 `Spring Security` 内部默认的实现,适用于前后端分离项目,详情参考 [`SecurityConfig.java`](./src/main/java/com/xkcoding/rbac/security/config/SecurityConfig.java) 和 [`AuthController.java`](./src/main/java/com/xkcoding/rbac/security/controller/AuthController.java)
- [x] 持久化技术使用 `spring-data-jpa` 完成
- [x] 使用 `JWT` 实现安全验证,同时引入 `Redis` 解决 `JWT` 无法手动设置过期的弊端,并且保证同一用户在同一时间仅支持同一设备登录,不同设备登录会将,详情参考 [`JwtUtil.java`](./src/main/java/com/xkcoding/rbac/security/util/JwtUtil.java)
- [x] 在线人数统计,详情参考 [`MonitorService.java`](./src/main/java/com/xkcoding/rbac/security/service/MonitorService.java) 和 [`RedisUtil.java`](./src/main/java/com/xkcoding/rbac/security/util/RedisUtil.java)
- [x] 手动踢出用户,详情参考 [`MonitorService.java`](./src/main/java/com/xkcoding/rbac/security/service/MonitorService.java)
- [x] 自定义配置不需要进行拦截的请求,详情参考 [`CustomConfig.java`](./src/main/java/com/xkcoding/rbac/security/config/CustomConfig.java) 和 [`application.yml`](./src/main/resources/application.yml)
## 2. 运行
### 2.1. 环境
1. JDK 1.8 以上
2. Maven 3.5 以上
3. Mysql 5.7 以上
4. Redis
### 2.2. 运行方式
1. 新建一个名为 `spring-boot-demo` 的数据库,字符集设置为 `utf-8`,如果数据库名不是 `spring-boot-demo` 需要在 `application.yml` 中修改 `spring.datasource.url`
2. 使用 [`security.sql`](./sql/security.sql) 这个 SQL 文件,创建数据库表和初始化RBAC数据
3. 运行 `SpringBootDemoRbacSecurityApplication`
4. 管理员账号:admin/123456 普通用户:user/123456
5. 使用 `POST` 请求访问 `/${contextPath}/api/auth/login` 端点,输入账号密码,登陆成功之后返回token,将获得的 token 放在具体请求的 Header 里,key 固定是 `Authorization` ,value 前缀为 `Bearer 后面加空格`再加token,并加上具体请求的参数,就可以了
6. enjoy ~ :kissing_smiling_eyes:
## 3. 部分关键代码
### 3.1. pom.xml
```xml
4.0.0
spring-boot-demo-rbac-security
1.0.0-SNAPSHOT
jar
spring-boot-demo-rbac-security
Demo project for Spring Boot
com.xkcoding
spring-boot-demo
1.0.0-SNAPSHOT
UTF-8
UTF-8
1.8
0.9.1
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-data-jpa
org.springframework.boot
spring-boot-starter-data-redis
org.apache.commons
commons-pool2
org.springframework.boot
spring-boot-configuration-processor
true
io.jsonwebtoken
jjwt
${jjwt.veersion}
mysql
mysql-connector-java
org.springframework.boot
spring-boot-starter-test
test
cn.hutool
hutool-all
org.projectlombok
lombok
true
spring-boot-demo-rbac-security
org.springframework.boot
spring-boot-maven-plugin
```
### 3.2. JwtUtil.java
> JWT 工具类,主要功能:生成JWT并存入Redis、解析JWT并校验其准确性、从Request的Header中获取JWT
```java
/**
*
* JWT 工具类
*
*
* @author yangkai.shen
* @date Created in 2018-12-07 13:42
*/
@EnableConfigurationProperties(JwtConfig.class)
@Configuration
@Slf4j
public class JwtUtil {
@Autowired
private JwtConfig jwtConfig;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 创建JWT
*
* @param rememberMe 记住我
* @param id 用户id
* @param subject 用户名
* @param roles 用户角色
* @param authorities 用户权限
* @return JWT
*/
public String createJWT(Boolean rememberMe, Long id, String subject, List roles, Collection extends GrantedAuthority> authorities) {
Date now = new Date();
JwtBuilder builder = Jwts.builder()
.setId(id.toString())
.setSubject(subject)
.setIssuedAt(now)
.signWith(SignatureAlgorithm.HS256, jwtConfig.getKey())
.claim("roles", roles)
.claim("authorities", authorities);
// 设置过期时间
Long ttl = rememberMe ? jwtConfig.getRemember() : jwtConfig.getTtl();
if (ttl > 0) {
builder.setExpiration(DateUtil.offsetMillisecond(now, ttl.intValue()));
}
String jwt = builder.compact();
// 将生成的JWT保存至Redis
stringRedisTemplate.opsForValue()
.set(Consts.REDIS_JWT_KEY_PREFIX + subject, jwt, ttl, TimeUnit.MILLISECONDS);
return jwt;
}
/**
* 创建JWT
*
* @param authentication 用户认证信息
* @param rememberMe 记住我
* @return JWT
*/
public String createJWT(Authentication authentication, Boolean rememberMe) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
return createJWT(rememberMe, userPrincipal.getId(), userPrincipal.getUsername(), userPrincipal.getRoles(), userPrincipal.getAuthorities());
}
/**
* 解析JWT
*
* @param jwt JWT
* @return {@link Claims}
*/
public Claims parseJWT(String jwt) {
try {
Claims claims = Jwts.parser()
.setSigningKey(jwtConfig.getKey())
.parseClaimsJws(jwt)
.getBody();
String username = claims.getSubject();
String redisKey = Consts.REDIS_JWT_KEY_PREFIX + username;
// 校验redis中的JWT是否存在
Long expire = stringRedisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS);
if (Objects.isNull(expire) || expire <= 0) {
throw new SecurityException(Status.TOKEN_EXPIRED);
}
// 校验redis中的JWT是否与当前的一致,不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期
String redisToken = stringRedisTemplate.opsForValue()
.get(redisKey);
if (!StrUtil.equals(jwt, redisToken)) {
throw new SecurityException(Status.TOKEN_OUT_OF_CTRL);
}
return claims;
} catch (ExpiredJwtException e) {
log.error("Token 已过期");
throw new SecurityException(Status.TOKEN_EXPIRED);
} catch (UnsupportedJwtException e) {
log.error("不支持的 Token");
throw new SecurityException(Status.TOKEN_PARSE_ERROR);
} catch (MalformedJwtException e) {
log.error("Token 无效");
throw new SecurityException(Status.TOKEN_PARSE_ERROR);
} catch (SignatureException e) {
log.error("无效的 Token 签名");
throw new SecurityException(Status.TOKEN_PARSE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Token 参数不存在");
throw new SecurityException(Status.TOKEN_PARSE_ERROR);
}
}
/**
* 设置JWT过期
*
* @param request 请求
*/
public void invalidateJWT(HttpServletRequest request) {
String jwt = getJwtFromRequest(request);
String username = getUsernameFromJWT(jwt);
// 从redis中清除JWT
stringRedisTemplate.delete(Consts.REDIS_JWT_KEY_PREFIX + username);
}
/**
* 根据 jwt 获取用户名
*
* @param jwt JWT
* @return 用户名
*/
public String getUsernameFromJWT(String jwt) {
Claims claims = parseJWT(jwt);
return claims.getSubject();
}
/**
* 从 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;
}
}
```
### 3.3. SecurityConfig.java
> Spring Security 配置类,主要功能:配置哪些URL不需要认证,哪些需要认证
```java
/**
*
* Security 配置
*
*
* @author yangkai.shen
* @date Created in 2018-12-07 16:46
*/
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(CustomConfig.class)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomConfig customConfig;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public BCryptPasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService)
.passwordEncoder(encoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
// 关闭 CSRF
.and()
.csrf()
.disable()
// 登录行为由自己实现,参考 AuthController#login
.formLogin()
.disable()
.httpBasic()
.disable()
// 认证请求
.authorizeRequests()
// 所有请求都需要登录访问
.anyRequest()
.authenticated()
// RBAC 动态 url 认证
.anyRequest()
.access("@rbacAuthorityService.hasPermission(request,authentication)")
// 登出行为由自己实现,参考 AuthController#logout
.and()
.logout()
.disable()
// Session 管理
.sessionManagement()
// 因为使用了JWT,所以这里不管理Session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 异常处理
.and()
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler);
// 添加自定义 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));
}
}
```
### 3.4. RbacAuthorityService.java
> 路由动态鉴权类,主要功能:
>
> 1. 校验请求的合法性,排除404和405这两种异常请求
> 2. 根据当前请求路径与该用户可访问的资源做匹配,通过则可以访问,否则,不允许访问
```java
/**
*
* 动态路由认证
*
*
* @author yangkai.shen
* @date Created in 2018-12-10 17:17
*/
@Component
public class RbacAuthorityService {
@Autowired
private RoleDao roleDao;
@Autowired
private PermissionDao permissionDao;
@Autowired
private RequestMappingHandlerMapping mapping;
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
checkRequest(request);
Object userInfo = authentication.getPrincipal();
boolean hasPermission = false;
if (userInfo instanceof UserDetails) {
UserPrincipal principal = (UserPrincipal) userInfo;
Long userId = principal.getId();
List roles = roleDao.selectByUserId(userId);
List roleIds = roles.stream()
.map(Role::getId)
.collect(Collectors.toList());
List permissions = permissionDao.selectByRoleIdList(roleIds);
//获取资源,前后端分离,所以过滤页面权限,只保留按钮权限
List btnPerms = permissions.stream()
// 过滤页面权限
.filter(permission -> Objects.equals(permission.getType(), Consts.BUTTON))
// 过滤 URL 为空
.filter(permission -> StrUtil.isNotBlank(permission.getUrl()))
// 过滤 METHOD 为空
.filter(permission -> StrUtil.isNotBlank(permission.getMethod()))
.collect(Collectors.toList());
for (Permission 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 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 SecurityException(Status.HTTP_BAD_METHOD);
} else {
return;
}
}
}
throw new SecurityException(Status.REQUEST_NOT_FOUND);
}
/**
* 获取 所有URL Mapping,返回格式为{"/test":["GET","POST"],"/sys":["GET","DELETE"]}
*
* @return {@link ArrayListMultimap} 格式的 URL Mapping
*/
private Multimap allUrlMapping() {
Multimap urlMapping = ArrayListMultimap.create();
// 获取url与类和方法的对应信息
Map handlerMethods = mapping.getHandlerMethods();
handlerMethods.forEach((k, v) -> {
// 获取当前 key 下的获取所有URL
Set 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;
}
}
```
### 3.5. JwtAuthenticationFilter.java
> JWT 认证过滤器,主要功能:
>
> 1. 过滤不需要拦截的请求
> 2. 根据当前请求的JWT,认证用户身份信息
```java
/**
*
* Jwt 认证过滤器
*
*
* @author yangkai.shen
* @date Created in 2018-12-10 15:15
*/
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private CustomConfig customConfig;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (checkIgnores(request)) {
filterChain.doFilter(request, response);
return;
}
String jwt = jwtUtil.getJwtFromRequest(request);
if (StrUtil.isNotBlank(jwt)) {
try {
String username = jwtUtil.getUsernameFromJWT(jwt);
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (SecurityException e) {
ResponseUtil.renderJson(response, e);
}
} else {
ResponseUtil.renderJson(response, Status.UNAUTHORIZED, null);
}
}
/**
* 请求是否不需要进行权限拦截
*
* @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 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;
}
}
```
### 3.6. CustomUserDetailsService.java
> 实现 `UserDetailsService` 接口,主要功能:根据用户名查询用户信息
```java
/**
*
* 自定义UserDetails查询
*
*
* @author yangkai.shen
* @date Created in 2018-12-10 10:29
*/
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserDao userDao;
@Autowired
private RoleDao roleDao;
@Autowired
private PermissionDao permissionDao;
@Override
public UserDetails loadUserByUsername(String usernameOrEmailOrPhone) throws UsernameNotFoundException {
User user = userDao.findByUsernameOrEmailOrPhone(usernameOrEmailOrPhone, usernameOrEmailOrPhone, usernameOrEmailOrPhone)
.orElseThrow(() -> new UsernameNotFoundException("未找到用户信息 : " + usernameOrEmailOrPhone));
List roles = roleDao.selectByUserId(user.getId());
List roleIds = roles.stream()
.map(Role::getId)
.collect(Collectors.toList());
List permissions = permissionDao.selectByRoleIdList(roleIds);
return UserPrincipal.create(user, roles, permissions);
}
}
```
### 3.7. RedisUtil.java
> 主要功能:根据key的格式分页获取Redis存在的key列表
```java
/**
*
* Redis工具类
*
*
* @author yangkai.shen
* @date Created in 2018-12-11 20:24
*/
@Component
@Slf4j
public class RedisUtil {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 分页获取指定格式key,使用 scan 命令代替 keys 命令,在大数据量的情况下可以提高查询效率
*
* @param patternKey key格式
* @param currentPage 当前页码
* @param pageSize 每页条数
* @return 分页获取指定格式key
*/
public PageResult findKeysForPage(String patternKey, int currentPage, int pageSize) {
ScanOptions options = ScanOptions.scanOptions()
.match(patternKey)
.build();
RedisConnectionFactory factory = stringRedisTemplate.getConnectionFactory();
RedisConnection rc = factory.getConnection();
Cursor cursor = rc.scan(options);
List result = Lists.newArrayList();
long tmpIndex = 0;
int startIndex = (currentPage - 1) * pageSize;
int end = currentPage * pageSize;
while (cursor.hasNext()) {
String key = new String(cursor.next());
if (tmpIndex >= startIndex && tmpIndex < end) {
result.add(key);
}
tmpIndex++;
}
try {
cursor.close();
RedisConnectionUtils.releaseConnection(rc, factory);
} catch (Exception e) {
log.warn("Redis连接关闭异常,", e);
}
return new PageResult<>(result, tmpIndex);
}
}
```
### 3.8. MonitorService.java
> 监控服务,主要功能:查询当前在线人数分页列表,手动踢出某个用户
```java
package com.xkcoding.rbac.security.service;
import cn.hutool.core.util.StrUtil;
import com.google.common.collect.Lists;
import com.xkcoding.rbac.security.common.Consts;
import com.xkcoding.rbac.security.common.PageResult;
import com.xkcoding.rbac.security.model.User;
import com.xkcoding.rbac.security.repository.UserDao;
import com.xkcoding.rbac.security.util.RedisUtil;
import com.xkcoding.rbac.security.vo.OnlineUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
*
* 监控 Service
*
*
* @author yangkai.shen
* @date Created in 2018-12-12 00:55
*/
@Service
public class MonitorService {
@Autowired
private RedisUtil redisUtil;
@Autowired
private UserDao userDao;
public PageResult onlineUser(Integer page, Integer size) {
PageResult keys = redisUtil.findKeysForPage(Consts.REDIS_JWT_KEY_PREFIX + Consts.SYMBOL_STAR, page, size);
List rows = keys.getRows();
Long total = keys.getTotal();
// 根据 redis 中键获取用户名列表
List usernameList = rows.stream()
.map(s -> StrUtil.subAfter(s, Consts.REDIS_JWT_KEY_PREFIX, true))
.collect(Collectors.toList());
// 根据用户名查询用户信息
List userList = userDao.findByUsernameIn(usernameList);
// 封装在线用户信息
List onlineUserList = Lists.newArrayList();
userList.forEach(user -> onlineUserList.add(OnlineUser.create(user)));
return new PageResult<>(onlineUserList, total);
}
}
```
### 3.9. 其余代码参见本 demo
## 4. 参考
1. Spring Security 官方文档:https://docs.spring.io/spring-security/site/docs/5.1.1.RELEASE/reference/htmlsingle/
2. JWT 官网:https://jwt.io/
3. JJWT开源工具参考:https://github.com/jwtk/jjwt#quickstart
4. 授权部分参考官方文档:https://docs.spring.io/spring-security/site/docs/5.1.1.RELEASE/reference/htmlsingle/#authorization
4. 动态授权部分,参考博客:https://blog.csdn.net/larger5/article/details/81063438