项目实战九-登录功能二
鉴权功能
- 上一节讲过
- ShiroCfg中向容器添加Realm(TokenRealm)对象用来鉴权
- ShiroFilterFactoryBean对象配置拦截url
- TokenFilter校验token
- TokenMatcher用来自定义匹配算法
- 那么如何才能使用TokenRealm进行鉴权呢?TokenFilter校验token之后,触发鉴权方法
-
TokenFilter的onAccessDenied方法,在对token进行校验之后,添加如下代码
// 鉴权(进入Realm) // 这里调用login,并不是“登录”的意思,是为了触发Realm(TokenRealm)的相应方法(AuthorizationInfo、AuthenticationInfo)去加载用户的角色、权限信息,以便鉴权 SecurityUtils.getSubject().login(new Token(token));
-
上面接收的Token类型必须是AuthenticationToken类型,因此自定义Token类
@Data public class Token implements AuthenticationToken { private final String token; public Token(String token) { this.token = token; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
- 只有先认证通过,才会加载授权信息
认证功能
-
TokenRealm代码如下
@Slf4j public class TokenRealm extends AuthorizingRealm { //自己定义一个密码的比对算法类,传进来 public TokenRealm (TokenMatcher matcher) { super(matcher); } /* 执行流程1: 先对token进行判断,返回true,才会调用doGetAuthorizationInfo、doGetAuthenticationInfo方法 * */ @Override public boolean supports(AuthenticationToken token) { log.debug("TokenRealm - supports - {}", token); //只有这个token类是自定义的Token类型,才会继续往下执行 return token instanceof Token; } /* * 执行流程3:加载权限信息 * */ //获取权限信息 @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return null; } /* 执行流程2: * 认证方法 * AuthenticationToken token:TokenFilter中调用login传递过来的token * * */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String tk = ((Token) token).getToken(); log.debug("TokenRealm - doGetAuthenticationInfo - {}", tk); //返回一个info,调用完该方法,会去调用Realm的matcher进行具体的密码校验,因此会到自定义的TokenMatcher类去执行方法,将token跟info传递给TokenMatcher return new SimpleAuthenticationInfo(tk, tk, getName()); } }
-
doGetAuthenticationInfo方法执行完,会调用当前Realm()的matcher(TokenMatcher)对象的doCredentialsMatch方法进行实际的认证
@Slf4j public class TokenMatcher implements CredentialsMatcher { /*自定义实现,客户端传递过来的值与数据库的值进行匹配的算法*/ @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { log.debug("TokenMatcher - doCredentialsMatch - {} {}", token, info); //这里就是个摆设,目的就是直接告诉密码没有问题,这样才会进入下一个环节,加载权限信息 return true; } }
- 该类中并没有去做实际的认证功能,直接放行,这一切都是为了到授权流程doGetAuthorizationInfo
- 因为真正的认证:密码验证在登录的impl中已经校验
授权功能
- 前面一切的目的都是为了到达这个doGetAuthorizationInfo方法,拿到用户传递过来的token
- TokenFilter校验token,拿到token调用login方法传递,该方法会触发TokenRealm的认证方法doGetAuthenticationInfo
- doGetAuthenticationInfo将token包装成AuthenticationInfo,然后直接通过TokenMatcher用户名密码认证放行,将AuthenticationInfo的principals传递给了doGetAuthorizationInfo
- 最终token传递到获取权限信息方法doGetAuthorizationInfo
- 该方法的作用:就是通过传递过来的token,来查当前用户是否有相关的权限信息
-
doGetAuthorizationInfo测试代码
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 拿到当前登录用户的token String token = (String) principals.getPrimaryPrincipal(); log.debug("TokenRealm - doGetAuthorizationInfo - {}", token); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); //进来的所有用户都有这个权限 info.addStringPermission("sysUser:list"); return info; }
- 上面代表,所有的用户都有
sysUser:list
这个权限
- 上面代表,所有的用户都有
-
在SysUserController的list方法,添加权限限制注解
//分页查询方法 @GetMapping //只有用户有sysUser:list权限,才会继续进入该接口 @RequiresPermissions("sysUser:list") public PageJsonVo<SysUserVo> list(SysUserPageReqVo reqVo) { return JsonVos.ok(service.list(reqVo)); }
-
在SysRoleController的list方法,添加权限限制注解
//分页查询方法 @GetMapping //只有用户有sysRole:list权限,才会继续进入该接口 @RequiresPermissions("sysRole:list") public PageJsonVo<SysRoleVo> list(SysRolePageReqVo reqVo) { return JsonVos.ok(service.list(reqVo)); }
- 此时运行后台项目、前端项目,在前端登录页面验证码显示错误,报错404,原因是如果添加了
@RequiresPermissions
注解,就会出现这样bug- 解决办法,在ShiroCfg中添加
/** * 解决:@RequiresPermissions导致控制器接口404 */ @Bean public DefaultAdvisorAutoProxyCreator proxyCreator() { DefaultAdvisorAutoProxyCreator proxyCreator = new DefaultAdvisorAutoProxyCreator(); proxyCreator.setUsePrefix(true); return proxyCreator; }
- 再次运行项目,在前端页面登录成功后
- 点击系统-》用户,正常显示,说明有权限
- 点击 系统-》角色,弹框提示:没有相关权限,说明该用户没有该菜单权限
- 注意:
@RequiresPermissions
注解的本质就是到doGetAuthorizationInfo中去查找当前用户是否有相关权限
权限控制
-
将所有的权限设置字符串常量放在Constants中
public static class Permisson { public static final String SYS_USER_LIST = "sysUser:list"; public static final String SYS_USER_ADD = "sysUser:add"; public static final String SYS_USER_UPDATE = "sysUser:update"; public static final String SYS_USER_REMOVE = "sysUser:remove"; public static final String SYS_ROLE_LIST = "sysRole:list"; public static final String SYS_ROLE_ADD = "sysRole:add"; public static final String SYS_ROLE_UPDATE = "sysRole:update"; public static final String SYS_ROLE_REMOVE = "sysRole:remove"; public static final String SYS_RESOURCE_LIST = "sysResource:list"; public static final String SYS_RESOURCE_ADD = "sysResource:add"; public static final String SYS_RESOURCE_UPDATE = "sysResource:update"; public static final String SYS_RESOURCE_REMOVE = "sysResource:remove"; public static final String DICT_ITEM_LIST = "dictItem:list"; public static final String DICT_ITEM_ADD = "dictItem:add"; public static final String DICT_ITEM_UPDATE = "dictItem:update"; public static final String DICT_ITEM_REMOVE = "dictItem:remove"; public static final String DICT_TYPE_LIST = "dictType:list"; public static final String DICT_TYPE_ADD = "dictType:add"; public static final String DICT_TYPE_UPDATE = "dictType:update"; public static final String DICT_TYPE_REMOVE = "dictType:remove"; public static final String EXAM_PLACE_LIST = "examPlace:list"; public static final String EXAM_PLACE_ADD = "examPlace:add"; public static final String EXAM_PLACE_UPDATE = "examPlace:update"; public static final String EXAM_PLACE_REMOVE = "examPlace:remove"; public static final String EXAM_PLACE_COURSE_LIST = "examPlaceCourse:list"; public static final String EXAM_PLACE_COURSE_ADD = "examPlaceCourse:add"; public static final String EXAM_PLACE_COURSE_UPDATE = "examPlaceCourse:update"; public static final String EXAM_PLACE_COURSE_REMOVE = "examPlaceCourse:remove"; public static final String PROVINCE_LIST = "province:list"; public static final String PROVINCE_ADD = "province:add"; public static final String PROVINCE_UPDATE = "province:update"; public static final String PROVINCE_REMOVE = "province:remove"; public static final String CITY_LIST = "city:list"; public static final String CITY_ADD = "city:add"; public static final String CITY_UPDATE = "city:update"; public static final String CITY_REMOVE = "city:remove"; }
- controller添加权限,给所有的controller添加注解权限
-
SysUserController
//只有用户有sysUser:list权限,才会继续进入该接口 @RequiresPermissions(Constants.Permisson.SYS_USER_LIST) public PageJsonVo<SysUserVo> list(SysUserPageReqVo reqVo){} //两个权限必须同时拥有logical = Logical.AND @RequiresPermissions(value = { Constants.Permisson.SYS_USER_UPDATE, Constants.Permisson.SYS_USER_ADD },logical = Logical.AND) public JsonVo save(@Valid SysUserReqVo reqVo) {} //重写父类的remove @RequiresPermissions(Constants.Permisson.SYS_USER_REMOVE) public JsonVo remove(String id)
- 其他控制器的api依次添加
- 注解分析:
@RequiresPermissions
:设置资源权限@RequiresRoles("总经理")
:设置角色权限
-
-
TokenRealm中doGetAuthorizationInfo添加权限查询代码
@Autowired private SysRoleService roleService; @Autowired private SysResourceService resourceService; @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 拿到当前登录用户的token String token = (String) principals.getPrimaryPrincipal(); log.debug("TokenRealm - doGetAuthorizationInfo - {}", token); /*在controller中添加的权限注解RequiresPermissions,本质就是到info中去查找*/ SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); //测试:给所有用户都添加这两个权限 //info.addStringPermission("sysUser:list"); //info.addStringPermission("sysRole:list"); //1. 根据token查找用户信息 SysUser user = Caches.getToken(token); //2. 根据用户id获取用户的所有角色pos,sys_role、sys_user_role List<SysRole> roles = roleService.listByUserId(user.getId()); if (CollectionUtils.isEmpty(roles)) return info; //2.1 将角色添加进info for (SysRole role : roles) { info.addRole(role.getName()); } //3. 根据所有的角色ids去获取所有的资源 // List<Short> roleIds = Streams.map(roles,(role)->role.getId()); List<Short> roleIds = Streams.map(roles, SysRole::getId); List<SysResource> resources = resourceService.listByRoleIds(roleIds); if (CollectionUtils.isEmpty(resources)) return info; //3.1 将用户所有的资源添加进info for (SysResource resource : resources) { info.addStringPermission(resource.getPermission()); } return info; }
- service层封装
-
SysRoleService添加根据用户id查询用户所有角色
//SysRoleService List<SysRole> listByUserId(Integer userId); //SysRoleServiceImpl /*根据用户id查询该用户所有角色pos*/ @Override @Transactional(readOnly = true) public List<SysRole> listByUserId(Integer userId) { if (userId == null || userId <= 0) return null; //1. 根据用户id获取所有的角色资源ids List<Short> ids = listIds(userId); if (CollectionUtils.isEmpty(ids)) return null; //2. 根据角色ids,查询所有对应的角色po MpLambdaQueryWrapper<SysRole> wrapper = new MpLambdaQueryWrapper<>(); //wrapper.in,只查询ids数组中对应的数据 wrapper.in(SysRole::getId, ids); //这种查询会有注入风险 //String sql = "SELECT role_id FROM sys_user_role WHERE user_id = " + userId; //wrapper.inSql(SysRole::getId, sql); return baseMapper.selectList(wrapper); }
-
SysResourceService添加根据角色ids查询所有的资源
//SysResourceService List<SysResource> listByRoleIds(List<Short> roleIds); //SysResourceServiceImpl /*根据角色ids查询所有的资源ids*/ private List<Short> listIds(List<Short> roleIds) { MpLambdaQueryWrapper<SysRoleResource> wrapper = new MpLambdaQueryWrapper<>(); wrapper.select(SysRoleResource::getResourceId); //in: 只查询roleids数组中对应的数据 wrapper.in(SysRoleResource::getRoleId, roleIds); List<Object> ids = roleResourceMapper.selectObjs(wrapper); //将Integer转化为short,同时去掉重复的资源,因为不同的角色有相同的资源,因此使用HashSet存储,保持唯一性 return Streams.map(new HashSet<>(ids), (id) -> ((Integer) id).shortValue()); } /*根据角色ids,查询所有的资源pos*/ @Override @Transactional(readOnly = true) public List<SysResource> listByRoleIds(List<Short> roleIds) { if (CollectionUtils.isEmpty(roleIds)) return null; //1. 根据roleids拿到所有resourcesids List<Short> ids = listIds(roleIds); if (CollectionUtils.isEmpty(ids)) return null; //2. 根据resourcesids获取所有的resources MpLambdaQueryWrapper<SysResource> wrapper = new MpLambdaQueryWrapper<>(); wrapper.in(SysResource::getId, ids); return baseMapper.selectList(wrapper); }
-
- 运行项目,登录各种不同用户权限的账号查看情况
性能改造
- 性能问题:上面的代码是调用每个API都会去通过TokenRealm的doGetAuthorizationInfo方法内部去查询数据库,是否有对应权限,当用户量大时比较消耗资源
- 改造方法:
- 在用户登录的时候到数据库去获取用户的角色信息、资源信息,然后缓存起来
- 在TokenRealm使用缓存,避免每次访问api都要从数据库获取,提高性能
- 步骤
-
新建SysUserDto,用于存储用户信息、用户角色信息、用户资源信息
//com.zh.jk.pojo.dto @Data public class SysUserDto { /*用户信息*/ private SysUser user; /*用户的角色信息*/ private List<SysRole> roles; /*用户的资源信息*/ private List<SysResource> resources; }
-
SysUserServiceImpl在登录时缓存用户、角色、资源信息
@Autowired private SysRoleService roleService; @Autowired private SysResourceService resourceService; @Override public LoginVo login(LoginReqVo reqVo) { //根据用户名查询用户 MpLambdaQueryWrapper<SysUser> wrapper = new MpLambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername,reqVo.getUsername()); SysUser po = baseMapper.selectOne(wrapper); if (po == null) { //用户名不存在 return JsonVos.raise(CodeMsg.WRONG_USERNAME); } //密码不正确 if (!po.getPassword().equals(reqVo.getPassword())){ return JsonVos.raise(CodeMsg.WRONG_PASSWORD); } //账号锁定 if(po.getStatus() == Constants.SysUserStatus.LOCKED){ return JsonVos.raise(CodeMsg.USER_LOCKED); } //更新登录时间 po.setLoginTime(new Date()); baseMapper.updateById(po); //生成token,发送token给用户 String token = UUID.randomUUID().toString(); /*************缓存用户权限信息********************/ //登录时将用户的权限信息获取,然后缓存起来,后序调用接口时,从缓存中获取,提升性能 SysUserDto dto = new SysUserDto(); dto.setUser(po); // 1. 根据用户id查询所有的角色:sys_role,sys_user_role List<SysRole> roles = roleService.listByUserId(po.getId()); // 2. 根据角色id查询所有的资源:sys_resource、sys_role_resource if (!CollectionUtils.isEmpty(roles)) { dto.setRoles(roles); List<Short> roleIds = Streams.map(roles, SysRole::getId); List<SysResource> resources = resourceService.listByRoleIds(roleIds); dto.setResources(resources); } //存储token到缓存中,使用EhCache封装的类 Caches.putToken(token, dto); // 返回给客户端的具体数据 LoginVo vo = MapStructs.INSTANCE.po2loginVo(po); vo.setToken(token); return vo; }
-
TokenRealm的doGetAuthorizationInfo中,直接从缓存中获取
@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 拿到当前登录用户的token String token = (String) principals.getPrimaryPrincipal(); log.debug("TokenRealm - doGetAuthorizationInfo - {}", token); /*在controller中添加的权限注解RequiresPermissions,本质就是到info中去查找*/ SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 1. 根据token查找SysUserDto,缓存的用户、资源、权限信息 SysUserDto user = Caches.getToken(token); //2. 到缓存中根据用户id获取用户的所有角色pos List<SysRole> roles = user.getRoles(); if (CollectionUtils.isEmpty(roles)) return info; //2.1 将角色添加进info for (SysRole role : roles) { info.addRole(role.getName()); } //3. 到缓存中根据所有的角色ids去获取所有的资源 List<SysResource> resources = user.getResources(); if (CollectionUtils.isEmpty(resources)) return info; //3.1 将用户所有的资源添加进info for (SysResource resource : resources) { info.addStringPermission(resource.getPermission()); } return info; }
-
权限更新及时性问题
- 上面的方法解决了每次查询权限的性能问题,但是带来了另外一个问题,即时性问题
- 如果给A用户添加、修改、删除 了一个角色、权限资源,那么是对A用户没有任何影响的,因为缓存中并没有将A用户的信息更新
- 解决办法:
- 如果修改(增加、删除、修改)了某个用户角色、资源,需要从缓存中将相应的角色、资源进行操作,同时将token失效,这样别的用户必须重新登录
- 需要在缓存中存储:key值为用户id,value为token的数据
- 当操作某个用户时,可以根据用户id获取对应的token,然后将token缓存删除
- 实现步骤
-
ehcache.xml中给token这个缓存添加监听器,用来监听缓存的增加、删除、修改
<listeners> <listener> <!--监听器类:里面添加需要监听的事件,操作内容--> <class>com.zh.jk.common.cache.TokenCacheListener</class> <!-- 触发监听时:使用 异步回调 --> <event-firing-mode>ASYNCHRONOUS</event-firing-mode> <!-- 触发监听时:不用按顺序处理事件 --> <event-ordering-mode>UNORDERED</event-ordering-mode> <!-- 哪些操作会触发监听器:添加、过期、删除 --> <events-to-fire-on>CREATED</events-to-fire-on> <events-to-fire-on>EXPIRED</events-to-fire-on> <events-to-fire-on>REMOVED</events-to-fire-on> </listener> </listeners>
-
创建监听器类TokenCacheListener
//com.zh.jk.common.cache //必须实现CacheEventListener public class TokenCacheListener implements CacheEventListener<Object, Object> { //监听token缓存 @Override public void onEvent(CacheEvent cacheEvent) { //1. 拿到key即token值。登录方法中:Caches.putToken(token, dto); String token = (String) cacheEvent.getKey(); switch (cacheEvent.getType()) { case CREATED: {// 监听:缓存有数据添加(说明有一个用户刚登录) // 2. 根据token获取到value即dto SysUserDto user = (SysUserDto) cacheEvent.getNewValue(); // 3. 添加以用户id为key,token为value的缓存,以便将来通过用户id找到他对应的token Caches.put(user.getUser().getId(), token); break; } case REMOVED: case EXPIRED: { //监听缓存有清除: token被移除或者过期了 SysUserDto user = (SysUserDto) cacheEvent.getOldValue(); //删除通过用户id获取token的缓存:用户id作为key,token作为value Caches.remove(user.getUser().getId()); break; } default: break; } } }
- 疑问:在Caches缓存的监听方法中使用put、remove,难道不会造成死循环吗?—不会
- 原因是:ehcache.xml文件中的配置监听,是添加在了“token”这个别名的配置中,即Caches类中的TOKEN_CACHE缓存中,即只监听TOKEN_CACHE这个缓存的增删改
- 监听方法中的put、remove 使用的是Caches中的DEFAULT_CACHE缓存,因此并不会触发监听事件,因此不会造成死循环
-
改造修改、添加、删除用户角色的方法: SysUserServiceImpl中saveOrUpdate
//编辑/添加 Integer id = reqVo.getId(); if (id != null && id > 0) { // 如果是做更新 // 将更新成功的用户从缓存中移除(让token失效,用户必须重新登录) //根据用户id获取token,然后将token从缓存中删除:token作为key,pto作为value Caches.removeToken(Caches.get(id)); .... } //保存角色信息 ...
-
改造修改、添加、删除角色资源信息的方法: SysRoleServiceImpl中saveOrUpdate
//2. 更新角色资源表sys_role_resource Short id = reqVo.getId(); if (id != null && id > 0) { MpLambdaQueryWrapper<SysUserRole> wrapper = new MpLambdaQueryWrapper<>(); wrapper.select(SysUserRole::getUserId); wrapper.eq(SysUserRole::getRoleId, id); //查询出这个角色对应的所有用户userIds List<Object> userIds = userRoleMapper.selectObjs(wrapper); if (!CollectionUtils.isEmpty(userIds)) { for (Object userId : userIds) { // 将拥有这个角色的用户从缓存中移除(让token失效,用户必须重新登录) Caches.removeToken(Caches.get(userId)); } } ... } // 保存资源信息 ...
-
- 测试:重新运行服务器;用两个浏览器一个登录admin,一个登录vvv;然后用admin修改vvv的角色或者资源信息,则VVV的浏览器点击其他页面会提示”token失效,重新登录“
退出登录
- 为什么token通常存放请求头?因为token不是业务参数,跟业务无关
-
SysUserController控制器添加logout方法
@PostMapping("logout") //RequestHeader,请求头必须传递一个token,否则就会报错 public JsonVo logout(@RequestHeader("Token") String token){ //清除token Caches.removeToken(token); return JsonVos.ok(); }