spring-boot-2+shiro+jwt集成实现权限管理
年前年后有空,花了些时间研究apache shiro。示例代码地址
https://github.com/CodingSoldier/java-learn/tree/master/project/shiro/shiro-jwt
如果不了解Apache shiro,我提供下我的学习路线:
1、先学慕课网的免费教程《Shiro安全框架入门》https://www.imooc.com/learn/977。
2、然后学慕课网的收费教程《Java开发企业级权限管理系统》,只学 第3章 Apache Shiro权限框架理论与实战演练https://coding.imooc.com/class/chapter/149.html#Anchor。
3、最后学shiro的文档,12版本有个中文文档,翻译水平不太好,比google翻译好一点吧。 https://www.ctolib.com/docs/sfile/apache-shiro-reference/
spring-boot-2+shiro+jwt的集成
pom.xml依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--spring-boot与shiro的整合包-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.4.2</version>
</dependency>
<!--jwt依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
shiro的subject、principals:
subject是一个安全术语,是指应用程序用户的特定安全的“视图”。一个 Shiro Subject 实例代表了一个单一应用程序用户的安全状态和操作。对于web应用,绝大多数情况下subject就是user,也可能是一个客户端。
principals(身份)是Subject的“标识属性”。比如:userId、用户名、客户端id。在另一个安全框架spring security中也是用principals作为用户的唯一标识。
可通过以下代码理解
@Test
public void test1(){
SimpleAccountRealm sar = new SimpleAccountRealm();
sar.addAccount("username01", "pwd01");
DefaultSecurityManager dsm = new DefaultSecurityManager();
dsm.setRealm(sar);
SecurityUtils.setSecurityManager(dsm);
Subject subject = SecurityUtils.getSubject();
System.out.println("subject未登陆,principals是null: "+subject.getPrincipals());
UsernamePasswordToken token = new UsernamePasswordToken("username01", "pwd01");
subject.login(token);
System.out.println("subject登陆成功后,principals是UsernamePasswordToken中的第一个参数: "+subject.getPrincipals());
}
为什么登陆成功后subject的身份属性principals是UsernamePasswordToken中的username呢?
因为UsernamePasswordToken源码覆盖了AuthenticationToken接口的getPrincipal()方法
public Object getPrincipal() {
return this.getUsername();
}
此方法返回UsernamePasswordToken的第一个参数username。
在默认情况下使用UsernamePasswordToken登陆,shiro会将principals和subject的认证状态存储在session中,但对于java来说session已经不太常用了,更常用的是token。本文将使用jwt生成token。
还有几个概念需要大家知道:
shiro中用户认证和用户授权是分开的。用户认证(可以理解为登陆)叫Authentication,用户授权叫Authorization,这两个单词长得还挺像。当碰到带有Authentication的类或方法就表明和认证相关,碰到Authorization就表明和授权相关。
Realm 是存储客户端安全数据如用户、角色、权限等的一个组件,可以理解为DAO。
shiro先用filter拦截请求,然后调用realm获取用户的认证、授权信息。
以下是一些重点代码
subject.login(token)方法接收的参数是AuthenticationToken,新建一个JwtToken继承AuthenticationToken。
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String token) {
this.token = token;
}
// token作为principal,
@Override
public Object getPrincipal() {
return this.token;
}
// 由于getPrincipal没返回username,而是返回token,所以credentials设置为空字符串即可
@Override
public Object getCredentials() {
return Constant.CREDENTIALS_EMPTY;
}
}
新建JwtFilter继承BasicHttpAuthenticationFilter,看到Authentication这个单词,大家就应该知道这个filter的作用是用户认证。
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
// 获取请求头Authorization的值
String authorization = getAuthzHeader(request);
return new JwtToken(authorization);
}
/**
* 执行登录操作
* 大部分代码跟父类一样,不同之处是catch到异常后返回自定义异常给前端
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
AuthenticationToken token = this.createToken(request, response);
if (token == null) {
String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken must be created in order to execute a login attempt.";
throw new IllegalStateException(msg);
} else {
try {
Subject subject = this.getSubject(request, response);
subject.login(token);
return this.onLoginSuccess(token, subject, request, response);
} catch (AuthenticationException e) {
Result result = Result.fail("用户认证异常");
if (e.getCause() instanceof CustomException){
CustomException customException = (CustomException)e.getCause();
result = Result.fail(customException.getCode(), customException.getMessage());
}
WebUtil.sendResponse(response, result);
return false;
}
}
}
/**
* 身份认证未通过,执行此方法
* 返回true,继续处理请求
* 返回false,不继续处理请求,结束过滤器链
* BasicHttpAuthenticationFilter源码中也是在onAccessDenied()方法内调用executeLogin
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
boolean r;
String authorization = getAuthzHeader(request);
if (StringUtils.isEmpty(authorization)){
WebUtil.sendResponse(response, Result.fail(Constant.CODE_TOKEN_ERROR,"无token"));
r = false;
}else {
r = executeLogin(request, response);
}
return r;
}
}
shiro默认的权限过滤器是PermissionsAuthorizationFilter,当用户没有接口的访问权限时,返回给前端的信息不友好,我们写一个CustomPermissionsAuthorizationFilter继承PermissionsAuthorizationFilter返回自定义信息给前端。看到Authorization这个单词,就知道这个filter是跟用户授权相关。
@Slf4j
public class CustomPermissionsAuthorizationFilter extends PermissionsAuthorizationFilter {
/**
* 用户无权访问url时,此方法会被调用
* 默认实现为org.apache.shiro.web.filter.authz.AuthorizationFilter#onAccessDenied()
* 覆盖父类的方法,返回自定义信息给前端
*
* 接口doc上说:
* AuthorizationFilter子类(权限授权过滤器)的onAccessDenied()应该永远返回false,那么在onAccessDenied()内就必然要发送response响应给前端,不然前端就收不到任何数据
* AuthenticationFilter、AuthenticatingFilter子类(身份认证过滤器)的onAccessDenied()的返回值则没有限制
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
WebUtil.sendResponse(response, Result.fail("权限不足"));
return false;
}
}
再建一个JwtRealm
public class JwtRealm extends AuthorizingRealm {
private UserService userService;
/**
* 最好保持单例
* JwtRealm可以不交给spring管理,在创建JwtRealm的时候需要创建者传递参数UserService
*/
public JwtRealm(UserService userService) {
this.userService = userService;
/**
* 启动认证缓存,默认是false。源码如下
* org.apache.shiro.realm.AuthenticatingRealm#AuthenticatingRealm()
* this.authenticationCachingEnabled = false;
*/
this.setAuthenticationCachingEnabled(true);
/**
* 启动授权缓存,默认就是true,代码如下
* org.apache.shiro.realm.AuthorizingRealm#AuthorizingRealm()
* this.authorizationCachingEnabled = true;
*/
// this.setAuthorizationCachingEnabled(true);
// 设置缓存管理器,使用shiro自带的MemoryConstrainedCacheManager即可
this.setCacheManager(new MemoryConstrainedCacheManager());
}
// subject.login(token)方法中的token是JwtToken时,调用此Realm的doGetAuthenticationInfo
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 认证用户
* 本方法被 org.apache.shiro.realm.AuthenticatingRealm#getAuthenticationInfo()调用
* AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
* 如果通过token在缓存中获取到用户认证,就不调用本方法
*
* 补充一点:用户认证接口往往只传递principals信息,不传credentials。在spring security中也是这种思路
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws CustomAuthenticationException {
String token = (String) authenticationToken.getPrincipal();
if (StringUtils.isEmpty(token)){
throw new CustomAuthenticationException("token为空");
}
String username = JWTUtil.getUsername(token);
User user = userService.getUser(username);
if (user == null){
throw new CustomAuthenticationException("无此用户");
}
/**
* token无效,抛出异常
* MyControllerAdvice捕获MyException异常后,将Constant.CODE_TOKEN_ERROR返回给前端,前端收到此code后跳转登录页
*/
if (!JWTUtil.verify(token, username, user.getPassword())){
throw new CustomAuthenticationException(Constant.CODE_TOKEN_ERROR, "token无效,请重新登录");
}
if (JWTUtil.isExpired(token)){
throw new CustomAuthenticationException(Constant.CODE_TOKEN_ERROR, "token无效,请重新登录");
}
return new SimpleAuthenticationInfo(token, Constant.CREDENTIALS_EMPTY, this.getName());
}
/**
* 用户授权
* 本方法被 org.apache.shiro.realm.AuthorizingRealm#getAuthorizationInfo() 调用
* Cache<Object, AuthorizationInfo> cache = this.getAvailableAuthorizationCache();
* 如果获取到缓存,就通过cache.get(key)获取授权数据,key就是principal
*
* 若无缓存,在调用了本方法后,会将本方法返回的AuthorizationInfo添加到缓存中
* cache.put(key, info);
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String username = JWTUtil.getUsername(principalCollection.getPrimaryPrincipal().toString());
User user = userService.getUser(username);
SimpleAuthorizationInfo sai = new SimpleAuthorizationInfo();
List<String> roleList = new ArrayList<>();
List<String> permissionList = new ArrayList<>();
if (!CollectionUtils.isEmpty(user.getRoleList())){
for (Role role:user.getRoleList()){
roleList.add(role.getName());
if (!CollectionUtils.isEmpty(role.getPermissionList())){
for (Permission permission:role.getPermissionList()){
permissionList.add(permission.getName());
}
}
}
}
sai.addRoles(roleList);
sai.addStringPermissions(permissionList);
return sai;
}
}
shiro配置类
@Configuration
public class ShiroConfig {
// 使用@Lazy避免UserService为空
@Lazy
@Autowired
UserService userService;
// 创建jwtRealm
@Bean
public JwtRealm jwtRealm(){
return new JwtRealm(userService);
}
@Bean
public DefaultWebSecurityManager securityManager(@Qualifier("jwtRealm") JwtRealm jwtRealm){
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 设置realm
manager.setRealm(jwtRealm);
/**
* 禁止session持久化存储
* 一定要禁止session持久化。不然清除认证缓存、授权缓存后,shiro依旧能从session中读取到认证信息
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("securityManager") DefaultWebSecurityManager securityManager){
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
//添加filter,factoryBean.getFilters()获取到的是引用,可直接添加值
factoryBean.getFilters().put("jwt", new JwtFilter());
factoryBean.getFilters().put("customPerms", new CustomPermissionsAuthorizationFilter());
/**
* factoryBean.getFilterChainDefinitionMap();默认是size=0的LinkedHashMap
*/
Map<String, String> definitionMap = factoryBean.getFilterChainDefinitionMap();
/**
* definitionMap是一个LinkedHashMap,是一个链表结构
* put的顺序很重要,当/open/**匹配到请求url后,将不再检查后面的规则
* 官方将这种原则称为 FIRST MATCH WINS
* https://waylau.gitbooks.io/apache-shiro-1-2-x-reference/content/III.%20Web%20Applications/10.%20Web.html
*/
definitionMap.put("/open/**", "anon");
/**
* 由于禁用了session存储,shiro不会存储用户的认证状态,所以在接口授权之前要先认证用户,不然CustomPermissionsAuthorizationFilter不知道用户是谁
* 实际项目中可以将这些接口权限规则放到数据库中去
*/
definitionMap.put("/user/add", "jwt, customPerms["+userService.getUserAdd().getName()+"]");
definitionMap.put("/user/delete", "jwt, customPerms["+userService.getUserDelete().getName()+"]");
definitionMap.put("/user/edit", "jwt, customPerms["+userService.getUserEdit().getName()+"]");
definitionMap.put("/user/view", "jwt, customPerms["+userService.getUserView().getName()+"]");
definitionMap.put("/test/other", "jwt, customPerms["+userService.getTestOther().getName()+"]");
definitionMap.put("/role/permission/edit", "jwt, customPerms["+userService.getRolePermisssionEdit().getName()+"]");
// 前面的规则都没匹配到,最后添加一条规则,所有的接口都要经过com.example.shirojwt.filter.JwtFilter这个过滤器验证
definitionMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(definitionMap);
return factoryBean;
}
}
请同学们先到我的github下载代码,在自己电脑上运行。
shiro在本项目中的认证、授权流程
创建一个登陆接口
@RestController
@Slf4j
@RequestMapping("/open")
public class OpenApiCtrl {
@Autowired
UserService userService;
/**
* 登陆
* 开放接口,不使用shiro拦截,生成令牌并返回给前端
* 用户名 密码
* admin admin-pwd
* cudrOtherUser cudrOtherUser-pwd
* viewUser viewUser-pwd
*/
@PostMapping("/login")
public Result openLogin(@RequestBody User userVo){
String username = userVo.getUsername();
String password = userVo.getPassword();
// 用户名密码校验
User user = userService.getUser(username);
if (user == null){
throw new CustomException("无此用户");
}
if (!user.getPassword().equals(new Md5Hash(password).toString())){
throw new CustomException("用户名或密码错误");
}
// 生成令牌
String token = JWTUtil.sign(username, user.getPassword());
/**
* 在登陆接口中就执行shiro用户认证,用于测试不禁用session存储的情形
*/
//JwtToken jwtToken = new JwtToken(token);
//Subject subject = SecurityUtils.getSubject();
//subject.login(jwtToken);
return Result.success(token);
}
}
前端拿到令牌token,之后的请求都在header带上Authorization=token
接着发一个请求
curl --location --request GET 'localhost:8080/user/add' \
--header 'Authorization: XXXXX'
此请求经过两个filter,JwtFilter和CustomPermissionsAuthorizationFilter,执行用户认证和授权。
认证流程是这样:
1、AccessControlFilter#onPreHandle()
2、AuthenticationFilter#isAccessAllowed(),此方法仅判断当前的subject(用户)是否已经认证,结果subject.authenticated是未认证。
3、JwtFilter#onAccessDenied(),用户未认证,此方法被调用。
4、JwtFilter#executeLogin(), 使用请求头Authorization生成JwtToken,然后执行subject.login(JwtToken);
5、AuthenticatingRealm#getAuthenticationInfo(),通过JwtToken查询缓存中的AuthenticationInfo
6、缓存中没有AuthenticationInfo,进入 JwtRealm#doGetAuthenticationInfo(),校验token合法性,并生成SimpleAuthenticationInfo。
7、DefaultWebSubjectFactory#createSubject,使用SimpleAuthenticationInfo中的principals生成一个已认证的subject,用户认证就完成了。
授权流程是这样:
1、AccessControlFilter#onPreHandle()
2、PermissionsAuthorizationFilter#isAccessAllowed(),判断subject是否具备接口权限。
3、AuthorizingRealm#getAuthorizationInfo(),获取授权缓存。
4、没有授权缓存,通过JwtRealm#doGetAuthorizationInfo()获取授权信息。
测试下使用session的情形。准备步骤如下:
1、将OpenApiCtrl#openLogin()中注释的代码放开。
JwtToken jwtToken = new JwtToken(token);
Subject subject = SecurityUtils.getSubject();
subject.login(jwtToken);
2、ShiroConfig#securityManager() 注释掉禁用session存储的代码。
//DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
//DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
//defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
//subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
//manager.setSubjectDAO(subjectDAO);
3、ShiroConfig#shiroFilterFactoryBean(),权限接口仅使用CustomPermissionsAuthorizationFilter
definitionMap.put("/user/add", "customPerms["+userService.getUserAdd().getName()+"]");
definitionMap.put("/user/delete", "customPerms["+userService.getUserDelete().getName()+"]");
definitionMap.put("/user/edit", "customPerms["+userService.getUserEdit().getName()+"]");
definitionMap.put("/user/view", "customPerms["+userService.getUserView().getName()+"]");
definitionMap.put("/test/other", "customPerms["+userService.getTestOther().getName()+"]");
definitionMap.put("/role/permission/edit", "customPerms["+userService.getRolePermisssionEdit().getName()+"]");
启动工程
1、先使用postman请求localhost:8080/open/login
2、再使用postman请求localhost:8080/user/view,但是header中不带Authorization,你会发现请求依旧能成功。
这是为什么呢?
1、找到 PermissionsAuthorizationFilter#isAccessAllowed(),这里面有一行代码
Subject subject = getSubject(request, response);
我们讲过了,在shiro中subject就是用户,subject中的principals如同用户id,那shiro怎么知道发起请求的用户是谁呢?
2、DefaultWebSubjectFactory#createSubject() 这个方法中有以下代码
PrincipalCollection principals = wsc.resolvePrincipals();
boolean authenticated = wsc.resolveAuthenticated();
大家debug源码就知道了,不禁用session的情况下,shiro把principals(用户身份)、authenticated(认证状态)存储到在session中了。
这就是为什么请求 localhost:8080/user/view 的时候不管带不带Authorization请求头都能获得授权的原因。
假如在postman中把cookies、session都删除
再次请求 localhost:8080/user/view。debug源码
PrincipalCollection principals = wsc.resolvePrincipals(); // principals为null
boolean authenticated = wsc.resolveAuthenticated(); // authenticated 为false
CustomPermissionsAuthorizationFilter#onAccessDenied()被调用,返回“权限不足”信息。
定期清除缓存
/**
* 测试清除jwtRealm缓存
* MemoryConstrainedCacheManager使用的是Map作为缓存,必须用定时器清理
*/
@GetMapping("/cache/test")
public Result test2(){
Cache<Object, AuthenticationInfo> authen = jwtRealm.getAuthenticationCache();
Cache<Object, AuthorizationInfo> author = jwtRealm.getAuthorizationCache();
log.info("当前缓存, 认证缓存 = {} 授权缓存 = {}", authen, author);
authen.clear();
author.clear();
log.info("缓存清除完成,认证缓存size = {} 授权缓存size = {}", authen.size(), author.size());
return Result.success("test");
}
修改权限配置
/**
* 测试动态修改接口授权配置
*/
@GetMapping("/definition/test")
public Result test1() throws Exception{
AbstractShiroFilter shiroFilter = (AbstractShiroFilter)shiroFilterFactoryBean.getObject();
PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) shiroFilter.getFilterChainResolver();
DefaultFilterChainManager manager = (DefaultFilterChainManager) filterChainResolver.getFilterChainManager();
// 清空老的权限控制
manager.getFilterChains().clear();
shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();
// 生成新的definitionMap
LinkedHashMap<String, String> definitionMap = new LinkedHashMap<>();
definitionMap.put("/open/**", "anon");
definitionMap.put("/user/delete", "jwt, customPerms["+userService.getUserDelete().getName()+"]");
definitionMap.put("/user/edit", "jwt, customPerms["+userService.getUserEdit().getName()+"]");
definitionMap.put("/user/add", "jwt, customPerms["+userService.getUserAdd().getName()+"]");
definitionMap.put("/user/view", "jwt, customPerms["+userService.getUserView().getName()+"]");
definitionMap.put("/test/other", "jwt, customPerms["+userService.getTestOther().getName()+"]");
definitionMap.put("/role/permission/edit", "jwt, customPerms["+userService.getRolePermisssionEdit().getName()+"]");
definitionMap.put("/**", "jwt");
shiroFilterFactoryBean.setFilterChainDefinitionMap(definitionMap);
// 重新构建生成权限过滤链
for (Map.Entry<String, String> entry : definitionMap.entrySet()) {
String url = entry.getKey();
String chainDefinition = entry.getValue();
manager.createChain(url, chainDefinition);
}
return Result.success("test");
}
更多推荐
所有评论(0)