开源我去年写的适用于先后端分离项目的权限控制框架——easylimit

去年我开发了一个适用于先后端分离项目的权限控制框架,后面我通过深思熟虑后决定开源出来,供你们使用以及参考其实现思路,就当作回馈社区吧。css

接下来我将分别从开发背景开发思路功能特性简单示例核心流程几个方面分别叙述。html

开发背景

前几年,那个时候的大部分系统都采用的是“服务端业务处理 + 前端页面模板”的项目开发模式,所以这种系统的权限控制比较好处理,能够经过后端校验权限,并在权限校验没有经过的状况下控制前端页面跳转到登陆页面或者错误页面。前端

目前这种项目开发模式的权限控制已经有比较成熟的方案——cookie-session认证机制,在Java Web项目中其主流解决方案是使用Spring Security或者Shiro等框架完成系统的权限控制。java

可是,最近这几年先后端分离项目开发模式开始逐渐兴起,其目的是为了解决前端和服务端耦合性太强的问题,方便前端页面能够随时更改,独立运行。此外,APP、微信公众号、小程序也能够看作是先后端分离项目模式的“前端”,由于核心业务逻辑仍然是在服务端处理,APP、微信公众号、小程序则主要进行数据展现。git

在先后端分离项目开发模式中,后端再也不拥有对前端页面的控制权,而这偏偏引发了如下几个新的问题。github

其一,当后端判断用户没有登陆或者权限不够时没法控制前端页面跳转到登陆页面/未受权页面;web

其二,在MVC项目开发模式中,每当前端页面请求后端都会携带当前用户的session_id,以便后端能够在SessionDAO中刷新用户会话,保持在线状态,可是在先后端分离项目开发模式中,前端页面(注:这里泛指浏览器、APP等数据展现端)可能很长时间才会请求一次后端接口(注:好比用户每隔几小时甚至几天才打开一次APP),这种现象致使后端没法让用户会话一直处于活动状态,并且即便花费大量内存代价人为延长用户会话的时效时间,其作法也显得极其低效;redis

其三,以往MVC项目开发模式中成熟的cookie-session认证机制严重依赖于浏览器的cookie机制,在APP等没有cookie的环境中将彻底无法使用。算法

由于上述所说的这几个问题,因此致使在先后端分离项目开发模式中只能抛弃传统的cookie-session认证机制,从新寻找新的权限控制方式。目前在Java Web项目中使用特别普遍的解决方案主要是:JSON Web Token (JWT)spring

所谓JWT,本质上是一种特殊格式的字符串(token),而后主要经过如下两个步骤实现权限控制:

  1. 用户登陆成功后,服务端给客户端返回一个JSON格式的令牌(token),即:JSON Web Token (JWT)。JWT一般由三部分组成: 头信息(header)消息体(payload)签名(signature) 。头信息指定了该JWT使用的签名算法;消息体包含了JWT的意图,好比令牌的过时时间,用户主体信息等内容;最后签名则主要是为了确保消息数据不被篡改。
  2. 客户端接收服务端返回的JWT,将其存储在cookieLocalStorage等其余客户端存储方案中,此后客户端将在与服务端交互中都会带上JWT。而后服务端在接收到JWT后再验证其是否合法,以及JWT中的权限信息是否被容许访问当前资源。

从JWT的实现原理咱们能够看出,JWT解决了一部分先后端分离项目开发模式引起的问题,可是它并无彻底解决,并且JWT在管理用户权限方面至少还存在如下几个方面的缺点:

  1. 更多的空间占用。使用JWT后服务器再也不保存会话状态,所以若是将服务端原有session中的各种信息都放在JWT中保存到客户端,可能形成JWT占用的空间太大问题;
  2. 没法做废已颁发的令牌。全部的认证信息都放在JWT中(注:JWT在客户端存储),再加之在服务端再也不保存会话状态,所以即便你知道某个JWT被盗取了也没法当即将其做废;
  3. 没法应对权限信息更新或者过时的问题。与上一条相似,在JWT中保存的用户权限信息若是在JWT令牌过时以前发生了更改,那么你除了忍受“过时”数据别无办法。

所以,在现现在WEB开发逐渐倾向于先后端分离、分布式等现实背景下,一种实现方式简单、功能完整、运行效率高且能够避免JWT的诸多缺点的权限控制模型及现成可用的框架已经成为一个亟待解决的问题。

开发思路

在借鉴了JWTApache Shiro的实现思路后,我开发了如今正在给你们介绍的这个 easylimit 框架。

在实现上,首先我扩展了RBAC权限模型,引入了访问令牌的概念。在 访问令牌-RBAC 权限模型中,针对目前主流的RBAC权限模型进行了扩展,前端页面(网页、APP、微信公众号、小程序等)再也不存储用于区分服务端用户会话的session_id,而是自行选择如何存储访问令牌access_token)和刷新令牌refresh_token)。其中,refresh_token用于在access_token过时后请求服务端生成新的access_token,而access_token则跟服务端的用户会话(session)一一对应,即一个access_token只对应于一个服务端的惟一用户标识。所以在用户携带access_token请求服务端后,服务端就能够根据access_token查找到与之关联的session,后面就跟RBAC权限模型的鉴权步骤同样了,也就是:根据session中的用户基本信息获取用户当前拥有的角色和权限,判断当前用户是否有权限请求该资源,若是没有就返回错误提示,有则继续往下执行。

“访问令牌-RBAC”权限模型

具体来说,在前端页面访问后端服务的过程当中,后端服务主要会执行如下几个核心操做:

  1. 未登陆用户在请求登陆时,系统首先为用户会话(session)分配一个惟一标识——session_id。而后登陆成功以后,自动查询当前登陆用户拥有的全部角色、权限,自动建立访问令牌(access_token)以及用于刷新访问令牌的刷新令牌(refresh_token)。须要说明的是,access_token所在对象关联了存储在服务端的session_id,所以能够经过access_token查询到用户所在会话(session),refresh_token所在对象关联了access_token,所以能够经过refresh_token来刷新access_token。
  2. 用户登陆成功以后,携带access_token再次请求系统业务接口,系统首先经过access_token查询到关联的会话ID(session_id),而后再根据session_id查询该用户在系统中的会话(session),最后根据会话中的用户基本信息获取用户当前拥有的角色和权限,以及判断当前用户是否有权限访问请求的资源,若是没有就返回错误提示,有则继续往下执行。

Shiro相比,easylimit这个框架的关键点在于,再也不使用“将session_id存储到Cookie以便关联用户会话”的模式,而是经过给用户返回访问令牌(access_token)和刷新令牌(refresh_token),让用户灵活选择如何存储这两个令牌,只要保证调用业务接口时携带上访问令牌(access_token)便可。此外,经过将refresh_token和access_token关联,保障了能够经过refresh_token不断生成新的access_token,经过access_token和存储在服务端的session_id关联,保障了能够经过access_token找到请求用户的会话(session),以便进行后续其余鉴权操做,而这种作法也刚好避免了JWT不能灵活做废已颁发的令牌以及没法随时更新用户权限信息的缺陷。

功能特性

在使用上,easylimit须要依赖spring-contextJacksonJedis这几个组件,而后主要提供了如下功能特性:

  • 同时支持MVC先后端分离项目开发模式的权限控制
  • 支持完整的RBAC权限控制
  • 默认实现多种session_id生成方式,包括:随机字符串UUID雪花算法
  • 默认实现多种sessiontoken存储方式,包括:基于ConcurrentHashMap的内存存储、使用Redis等缓存存储
  • 默认实现AOP切面,支持多种权限控制注解,包括:@RequiresLogin@RequiresPermissions@RequiresRoles
  • 默认支持多种Access Token传参方式,且能够灵活扩展
  • 默认实现“是否踢出当前用户的旧会话”的选项
  • 默认实现多种登陆登陆方式、多种密码校验规则的简单接入。前者包括:“用户名+密码”登陆、“手机号码+短信验证码”登陆,后者包括:Base64Md5HexSha256HexSha512HexMd5CryptSha256Crypt等其余自定义密码加密/摘要方式
  • 使用简单,可扩展性强
  • 代码规范,注释完整,文档齐全,有助于经过源码学习其实现思路

简单示例

(1)MVC项目开发模式的权限控制

i)pom.xml中添加依赖:

<dependency>
    <groupId>cn.zifangsky</groupId>
    <artifactId>easylimit</artifactId>
    <version>1.0.0-RELEASE</version>
</dependency>
复制代码

ii)自定义登陆方式,以及角色、权限相关信息的获取方式:

package cn.zifangsky.easylimit.example.easylimit;

import cn.zifangsky.easylimit.access.Access;
import cn.zifangsky.easylimit.authc.PrincipalInfo;
import cn.zifangsky.easylimit.authc.ValidatedInfo;
import cn.zifangsky.easylimit.authc.impl.SimplePrincipalInfo;
import cn.zifangsky.easylimit.authc.impl.UsernamePasswordValidatedInfo;
import cn.zifangsky.easylimit.example.mapper.SysFunctionMapper;
import cn.zifangsky.easylimit.example.mapper.SysRoleMapper;
import cn.zifangsky.easylimit.example.mapper.SysUserMapper;
import cn.zifangsky.easylimit.example.model.SysFunction;
import cn.zifangsky.easylimit.example.model.SysRole;
import cn.zifangsky.easylimit.example.model.SysUser;
import cn.zifangsky.easylimit.exception.authc.AuthenticationException;
import cn.zifangsky.easylimit.permission.PermissionInfo;
import cn.zifangsky.easylimit.permission.impl.SimplePermissionInfo;
import cn.zifangsky.easylimit.realm.impl.AbstractPermissionRealm;
import cn.zifangsky.easylimit.utils.SecurityUtils;

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

/** * 自定义{@link cn.zifangsky.easylimit.realm.Realm} * * @author zifangsky * @date 2019/5/28 * @since 1.0.0 */
public class CustomRealm extends AbstractPermissionRealm {

    private SysUserMapper sysUserMapper;

    private SysRoleMapper sysRoleMapper;

    private SysFunctionMapper sysFunctionMapper;

    public CustomRealm(SysUserMapper sysUserMapper, SysRoleMapper sysRoleMapper, SysFunctionMapper sysFunctionMapper) {
        this.sysUserMapper = sysUserMapper;
        this.sysRoleMapper = sysRoleMapper;
        this.sysFunctionMapper = sysFunctionMapper;
    }

    /** * 自定义“角色+权限”信息的获取方式 */
    @Override
    protected PermissionInfo doGetPermissionInfo(PrincipalInfo principalInfo) {
        SimplePermissionInfo permissionInfo = null;

        //获取用户信息
        SysUser sysUser = (SysUser) principalInfo.getPrincipal();
        if(sysUser != null){

            //经过用户ID查询角色权限信息
            Set<SysRole> roleSet = sysRoleMapper.selectByUserId(sysUser.getId());
            if(roleSet != null && roleSet.size() > 0){
                //全部角色名
                Set<String> roleNames = new HashSet<>(roleSet.size());
                //全部权限的code集合
                Set<String> funcCodes = new HashSet<>();

                for(SysRole role : roleSet){
                    roleNames.add(role.getName());

                    Set<SysFunction> functionSet = sysFunctionMapper.selectByRoleId(role.getId());
                    if(functionSet != null && functionSet.size() > 0){
                        funcCodes.addAll(functionSet.stream().map(SysFunction::getPathUrl).collect(Collectors.toSet()));
                    }
                }

                //实例化
                permissionInfo = new SimplePermissionInfo(roleNames, funcCodes);
            }
        }

        return permissionInfo;
    }

    /** * 自定义从表单的验证信息获取数据库中正确的用户主体信息 */
    @Override
    protected PrincipalInfo doGetPrincipalInfo(ValidatedInfo validatedInfo) throws AuthenticationException {
        //已知是“用户名+密码”的登陆模式
        UsernamePasswordValidatedInfo usernamePasswordValidatedInfo = (UsernamePasswordValidatedInfo) validatedInfo;

        SysUser sysUser = sysUserMapper.selectByUsername(usernamePasswordValidatedInfo.getSubject());

        return new SimplePrincipalInfo(sysUser, sysUser.getUsername(), sysUser.getPassword());
    }

    /** * <p>提示:在修改用户主体信息、角色、权限等接口时,须要手动调用此方法清空缓存的PrincipalInfo和PermissionInfo</p> */
    protected void clearCache() {
        //1. 获取本次请求实例
        Access access = SecurityUtils.getAccess();
        //2. 获取PrincipalInfo
        PrincipalInfo principalInfo = access.getPrincipalInfo();
        //3. 清理缓存
        super.doClearCache(principalInfo);
    }

}
复制代码

iii)添加easylimit框架的配置:

package cn.zifangsky.easylimit.example.config;

import cn.zifangsky.easylimit.DefaultWebSecurityManager;
import cn.zifangsky.easylimit.SecurityManager;
import cn.zifangsky.easylimit.cache.Cache;
import cn.zifangsky.easylimit.cache.impl.DefaultRedisCache;
import cn.zifangsky.easylimit.enums.ProjectModeEnums;
import cn.zifangsky.easylimit.example.easylimit.CustomRealm;
import cn.zifangsky.easylimit.example.mapper.SysFunctionMapper;
import cn.zifangsky.easylimit.example.mapper.SysRoleMapper;
import cn.zifangsky.easylimit.example.mapper.SysUserMapper;
import cn.zifangsky.easylimit.filter.impl.support.DefaultFilterEnums;
import cn.zifangsky.easylimit.filter.impl.support.FilterRegistrationFactoryBean;
import cn.zifangsky.easylimit.permission.aop.PermissionsAnnotationAdvisor;
import cn.zifangsky.easylimit.realm.Realm;
import cn.zifangsky.easylimit.session.SessionDAO;
import cn.zifangsky.easylimit.session.SessionIdFactory;
import cn.zifangsky.easylimit.session.SessionManager;
import cn.zifangsky.easylimit.session.impl.AbstractWebSessionManager;
import cn.zifangsky.easylimit.session.impl.MemorySessionDAO;
import cn.zifangsky.easylimit.session.impl.support.CookieWebSessionManager;
import cn.zifangsky.easylimit.session.impl.support.RandomCharacterSessionIdFactory;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.filter.DelegatingFilterProxy;

import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;

/** * EasyLimit框架的配置 * * @author zifangsky * @date 2019/5/28 * @since 1.0.0 */
@Configuration
public class EasyLimitConfig {

    /** * 配置缓存 */
    @Bean
    public Cache cache(RedisTemplate<String, Object> redisTemplate){
        return new DefaultRedisCache(redisTemplate);
    }

    /** * 配置Realm */
    @Bean
    public Realm realm(SysUserMapper sysUserMapper, SysRoleMapper sysRoleMapper, SysFunctionMapper sysFunctionMapper, Cache cache){
        CustomRealm realm = new CustomRealm(sysUserMapper, sysRoleMapper, sysFunctionMapper);
        //缓存主体信息
        realm.setEnablePrincipalInfoCache(true);
        realm.setPrincipalInfoCache(cache);

        //缓存角色、权限信息
        realm.setEnablePermissionInfoCache(true);
        realm.setPermissionInfoCache(cache);

        return realm;
    }

    /** * 配置Session的存储方式 */
    @Bean
    public SessionDAO sessionDAO(Cache cache){
        return new MemorySessionDAO();
    }

    /** * 配置session管理器 */
    @Bean
    public AbstractWebSessionManager sessionManager(SessionDAO sessionDAO){
// CookieInfo cookieInfo = new CookieInfo("custom_session_id");
        AbstractWebSessionManager sessionManager = new CookieWebSessionManager(/*cookieInfo*/);
        sessionManager.setSessionDAO(sessionDAO);

        //设置session超时时间为1小时
        sessionManager.setGlobalTimeout(1L);
        sessionManager.setGlobalTimeoutChronoUnit(ChronoUnit.HOURS);

        //设置定时校验的时间为2分钟
        sessionManager.setSessionValidationInterval(2L);
        sessionManager.setSessionValidationUnit(TimeUnit.MINUTES);

        //设置sessionId的生成方式
// SessionIdFactory sessionIdFactory = new SnowFlakeSessionIdFactory(1L, 1L);
        SessionIdFactory sessionIdFactory = new RandomCharacterSessionIdFactory();
        sessionManager.setSessionIdFactory(sessionIdFactory);

        return sessionManager;
    }

    /** * 认证、权限、session等管理的入口 */
    @Bean
    public SecurityManager securityManager(Realm realm, SessionManager sessionManager){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(realm, sessionManager);
        //踢出当前用户的旧会话
        securityManager.setKickOutOldSessions(true);

        return securityManager;
    }

    /** * 将filter添加到Spring管理 */
    @Bean
    public FilterRegistrationFactoryBean filterRegistrationFactoryBean(SecurityManager securityManager){
        //添加指定路径的权限校验
        LinkedHashMap<String, String[]> patternPathFilterMap = new LinkedHashMap<>();
        patternPathFilterMap.put("/css/**", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
        patternPathFilterMap.put("/layui/**", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
        patternPathFilterMap.put("/index.html", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
        patternPathFilterMap.put("/test/greeting", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
// patternPathFilterMap.put("/test/selectByUsername", new String[]{"perms[/aaa/bbb]"});
        //其余路径须要登陆才能访问
        patternPathFilterMap.put("/**/*.html", new String[]{DefaultFilterEnums.LOGIN.getFilterName()});

        FilterRegistrationFactoryBean factoryBean = new FilterRegistrationFactoryBean(ProjectModeEnums.DEFAULT, securityManager, patternPathFilterMap);

        //设置几个登陆、未受权等相关URL
        factoryBean.setLoginUrl("/login.html");
        factoryBean.setLoginCheckUrl("/check");
        factoryBean.setUnauthorizedUrl("/error.html");

        return factoryBean;
    }

    @Bean
    public FilterRegistrationBean<DelegatingFilterProxy> delegatingFilterProxy() {
        FilterRegistrationBean<DelegatingFilterProxy> filterRegistrationBean = new FilterRegistrationBean<>();
        DelegatingFilterProxy proxy = new DelegatingFilterProxy();
        proxy.setTargetFilterLifecycle(true);
        proxy.setTargetBeanName("filterRegistrationFactoryBean");
        filterRegistrationBean.setFilter(proxy);
        return filterRegistrationBean;
    }

    /** * 添加对权限注解的支持 */
    @Bean
    public PermissionsAnnotationAdvisor permissionsAnnotationAdvisor(){
        return new PermissionsAnnotationAdvisor("execution(* cn.zifangsky..controller..*.*(..))");
    }

}
复制代码

iv)添加测试代码:

登陆注销相关示例:

/** * 登陆验证 * @author zifangsky * @date 2019/5/29 13:23 * @since 1.0.0 * @return java.util.Map<java.lang.String,java.lang.Object> */
@PostMapping(value = "/check", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> check(HttpServletRequest request){
    Map<String,Object> result = new HashMap<>(4);
    result.put("code",500);

    try {
        //用户名
        String username = request.getParameter("username");
        //密码
        String password = request.getParameter("password");
        //获取本次请求实例
        Access access = SecurityUtils.getAccess();

        if(StringUtils.isBlank(username) || StringUtils.isBlank(password)){
            result.put("msg","请求参数不能为空!");
            return result;
        }else{
            logger.debug(MessageFormat.format("用户[{0}]正在请求登陆", username));

            //设置验证信息
            ValidatedInfo validatedInfo = new UsernamePasswordValidatedInfo(username, password, EncryptionTypeEnums.Sha256Crypt);

            //1. 登陆验证
            access.login(validatedInfo);
        }

        Session session = access.getSession();

        //2. 返回给页面的数据
        //登陆成功以后的回调地址
        String redirectUrl = (String) session.getAttribute(cn.zifangsky.easylimit.common.Constants.SAVED_SOURCE_URL_NAME);
        session.removeAttribute(cn.zifangsky.easylimit.common.Constants.SAVED_SOURCE_URL_NAME);

        if(StringUtils.isNoneBlank(redirectUrl)){
            result.put("redirect_uri", redirectUrl);
        }
        result.put("code",200);
    }catch (Exception e){
        result.put("code", 500);
        result.put("msg", "登陆失败,用户名或密码错误!");

        logger.error("登陆失败",e);
    }

    return result;
}

/** * 退出登陆 * @author zifangsky * @date 2019/5/29 17:44 * @since 1.0.0 * @return java.util.Map<java.lang.String,java.lang.Object> */
@PostMapping(value = "/logout.html", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> logout(HttpServletRequest request){
    Map<String,Object> result = new HashMap<>(1);

    Access access = SecurityUtils.getAccess();
    SysUser user = (SysUser) access.getPrincipalInfo().getPrincipal();

    if(user != null){
        logger.debug(MessageFormat.format("用户[{0}]正在退出登陆", user.getUsername()));
    }

    try {
        //1. 退出登陆
        access.logout();

        //2. 返回状态码
        result.put("code", 200);
    }catch (Exception e){
        result.put("code",500);
    }

    return result;
}
复制代码

权限校验注解示例:

目前默认提供了如下三个权限校验注解(可扩展):

  • @RequiresLogin
  • @RequiresPermissions
  • @RequiresRoles
@ResponseBody
@RequiresPermissions("/aaa/bbb")
@RequestMapping(value = "/selectByUsername", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public SysUser selectByUsername(String username) {
    return testService.selectByUsername(username);
}
复制代码

(2)先后端分离项目开发模式的权限控制

i)pom.xml中添加依赖:

这一步不用多说,跟上面同样。

ii)自定义登陆方式,以及角色、权限相关信息的获取方式:

这一步也跟上面同样。

iii)添加easylimit框架的配置:

easylimit框架的配置过程当中,与MVC项目开发模式相比不一样的地方有三处。第一是须要将SessionManager设置为TokenWebSessionManager及其子类;第二是须要将SecurityManager设置为TokenWebSecurityManager及其子类;第三是须要设置当前项目模式为ProjectModeEnums.TOKEN

package cn.zifangsky.easylimit.token.example.config;

import cn.zifangsky.easylimit.SecurityManager;
import cn.zifangsky.easylimit.TokenWebSecurityManager;
import cn.zifangsky.easylimit.cache.Cache;
import cn.zifangsky.easylimit.cache.impl.DefaultRedisCache;
import cn.zifangsky.easylimit.enums.ProjectModeEnums;
import cn.zifangsky.easylimit.filter.impl.support.DefaultFilterEnums;
import cn.zifangsky.easylimit.filter.impl.support.FilterRegistrationFactoryBean;
import cn.zifangsky.easylimit.permission.aop.PermissionsAnnotationAdvisor;
import cn.zifangsky.easylimit.realm.Realm;
import cn.zifangsky.easylimit.session.SessionDAO;
import cn.zifangsky.easylimit.session.SessionIdFactory;
import cn.zifangsky.easylimit.session.TokenDAO;
import cn.zifangsky.easylimit.session.impl.DefaultTokenOperateResolver;
import cn.zifangsky.easylimit.session.impl.MemorySessionDAO;
import cn.zifangsky.easylimit.session.impl.support.DefaultCacheTokenDAO;
import cn.zifangsky.easylimit.session.impl.support.RandomCharacterSessionIdFactory;
import cn.zifangsky.easylimit.session.impl.support.TokenInfo;
import cn.zifangsky.easylimit.session.impl.support.TokenWebSessionManager;
import cn.zifangsky.easylimit.token.example.easylimit.CustomRealm;
import cn.zifangsky.easylimit.token.example.mapper.SysFunctionMapper;
import cn.zifangsky.easylimit.token.example.mapper.SysRoleMapper;
import cn.zifangsky.easylimit.token.example.mapper.SysUserMapper;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.filter.DelegatingFilterProxy;

import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;

/** * EasyLimit框架的配置 * * @author zifangsky * @date 2019/5/28 * @since 1.0.0 */
@Configuration
public class EasyLimitConfig {

    /** * 配置缓存 */
    @Bean
    public Cache cache(RedisTemplate<String, Object> redisTemplate){
        return new DefaultRedisCache(redisTemplate);
    }

    /** * 配置Realm */
    @Bean
    public Realm realm(SysUserMapper sysUserMapper, SysRoleMapper sysRoleMapper, SysFunctionMapper sysFunctionMapper, Cache cache){
        CustomRealm realm = new CustomRealm(sysUserMapper, sysRoleMapper, sysFunctionMapper);
        //缓存主体信息
        realm.setEnablePrincipalInfoCache(true);
        realm.setPrincipalInfoCache(cache);

        //缓存角色、权限信息
        realm.setEnablePermissionInfoCache(true);
        realm.setPermissionInfoCache(cache);

        return realm;
    }

    /** * 配置Session的存储方式 */
    @Bean
    public SessionDAO sessionDAO(Cache cache){
        return new MemorySessionDAO();
    }

    /** * 配置Token的存储方式 */
    @Bean
    public TokenDAO tokenDAO(Cache cache){
        return new DefaultCacheTokenDAO(cache);
    }

    /** * 配置session管理器 */
    @Bean
    public TokenWebSessionManager sessionManager(SessionDAO sessionDAO, TokenDAO tokenDAO){
        TokenInfo tokenInfo = new TokenInfo();
        tokenInfo.setAccessTokenTimeout(2L);
        tokenInfo.setAccessTokenTimeoutUnit(ChronoUnit.MINUTES);
        tokenInfo.setRefreshTokenTimeout(1L);
        tokenInfo.setRefreshTokenTimeoutUnit(ChronoUnit.DAYS);

        //建立基于Token的session管理器
        TokenWebSessionManager sessionManager = new TokenWebSessionManager(tokenInfo,new DefaultTokenOperateResolver(), tokenDAO);
        sessionManager.setSessionDAO(sessionDAO);

        //设置定时校验的时间为3分钟
        sessionManager.setSessionValidationInterval(3L);
        sessionManager.setSessionValidationUnit(TimeUnit.MINUTES);

        //设置sessionId的生成方式
        SessionIdFactory sessionIdFactory = new RandomCharacterSessionIdFactory();
        sessionManager.setSessionIdFactory(sessionIdFactory);

        return sessionManager;
    }

    /** * 认证、权限、session等管理的入口 */
    @Bean
    public SecurityManager securityManager(Realm realm, TokenWebSessionManager sessionManager){
        return new TokenWebSecurityManager(realm, sessionManager);
    }

    /** * 将filter添加到Spring管理 */
    @Bean
    public FilterRegistrationFactoryBean filterRegistrationFactoryBean(SecurityManager securityManager){
        //添加指定路径的权限校验
        LinkedHashMap<String, String[]> patternPathFilterMap = new LinkedHashMap<>();
        patternPathFilterMap.put("/css/**", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
        patternPathFilterMap.put("/layui/**", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
// patternPathFilterMap.put("/test/greeting", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
        patternPathFilterMap.put("/refreshToken", new String[]{DefaultFilterEnums.ANONYMOUS.getFilterName()});
// patternPathFilterMap.put("/test/selectByUsername", new String[]{"perms[/aaa/bbb]"});
        //其余路径须要登陆才能访问
        patternPathFilterMap.put("/**", new String[]{DefaultFilterEnums.LOGIN.getFilterName()});

        FilterRegistrationFactoryBean factoryBean = new FilterRegistrationFactoryBean(ProjectModeEnums.TOKEN, securityManager, patternPathFilterMap);

        //设置几个登陆、未受权等相关URL
        factoryBean.setLoginCheckUrl("/login");

        return factoryBean;
    }

    @Bean
    public FilterRegistrationBean<DelegatingFilterProxy> delegatingFilterProxy() {
        FilterRegistrationBean<DelegatingFilterProxy> filterRegistrationBean = new FilterRegistrationBean<>();
        DelegatingFilterProxy proxy = new DelegatingFilterProxy();
        proxy.setTargetFilterLifecycle(true);
        proxy.setTargetBeanName("filterRegistrationFactoryBean");
        filterRegistrationBean.setFilter(proxy);
        return filterRegistrationBean;
    }

    /** * 添加对权限注解的支持 */
    @Bean
    public PermissionsAnnotationAdvisor permissionsAnnotationAdvisor(){
        return new PermissionsAnnotationAdvisor("execution(* cn.zifangsky..controller..*.*(..))");
    }

}
复制代码

iv)添加测试代码:

登陆注销相关示例:

/** * 登陆验证 * @author zifangsky * @date 2019/5/29 13:23 * @since 1.0.0 * @return java.util.Map<java.lang.String,java.lang.Object> */
@PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> check(HttpServletRequest request){
    Map<String,Object> result = new HashMap<>(4);
    result.put("code",500);

    try {
        //用户名
        String username = request.getParameter("username");
        //密码
        String password = request.getParameter("password");
        //获取本次请求实例
        ExposedTokenAccess access = (ExposedTokenAccess) SecurityUtils.getAccess();

        if(StringUtils.isBlank(username) || StringUtils.isBlank(password)){
            result.put("msg","请求参数不能为空!");
            return result;
        }else{
            logger.debug(MessageFormat.format("用户[{0}]正在请求登陆", username));

            //设置验证信息
            ValidatedInfo validatedInfo = new UsernamePasswordValidatedInfo(username, password, EncryptionTypeEnums.Sha256Crypt);

            //1. 登陆验证
            access.login(validatedInfo);
        }

        //2. 获取Access Token和Refresh Token
        SimpleAccessToken accessToken = access.getAccessToken();
        SimpleRefreshToken refreshToken = access.getRefreshToken();

        //3. 返回给页面的数据
        result.put("code",200);
        result.put("access_token", accessToken.getAccessToken());
        result.put("refresh_token", refreshToken.getRefreshToken());
        result.put("expires_in", accessToken.getExpiresIn());
// result.put("user_info", accessToken.getPrincipalInfo().getPrincipal());
    }catch (Exception e){
        result.put("code", 500);
        result.put("msg", "登陆失败,用户名或密码错误!");

        logger.error("登陆失败",e);
    }

    return result;
}


/** * 退出登陆 * @author zifangsky * @date 2019/5/29 17:44 * @since 1.0.0 * @return java.util.Map<java.lang.String,java.lang.Object> */
@PostMapping(value = "/logout", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> logout(HttpServletRequest request){
    Map<String,Object> result = new HashMap<>(1);

    Access access = SecurityUtils.getAccess();
    SysUser user = (SysUser) access.getPrincipalInfo().getPrincipal();

    if(user != null){
        logger.debug(MessageFormat.format("用户[{0}]正在退出登陆", user.getUsername()));
    }

    try {
        //1. 退出登陆
        access.logout();

        //2. 返回状态码
        result.put("code", 200);
    }catch (Exception e){
        result.put("code",500);
    }

    return result;
}
复制代码

刷新Access Token相关示例:

/** * 刷新Access Token * @author zifangsky * @date 2019/5/29 13:23 * @since 1.0.0 * @return java.util.Map<java.lang.String,java.lang.Object> */
@RequestMapping(value = "/refreshToken", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> refreshAccessToken(HttpServletRequest request){
    Map<String,Object> result = new HashMap<>(4);
    result.put("code",500);

    try {
        //Refresh Token
        String refreshTokenStr = request.getParameter("refresh_token");
        //获取本次请求实例
        ExposedTokenAccess access = (ExposedTokenAccess) SecurityUtils.getAccess();

        //1. 刷新Access Token
        SimpleAccessToken newAccessToken = access.refreshAccessToken(refreshTokenStr);

        //2. 返回给页面的数据
        result.put("code",200);
        result.put("access_token", newAccessToken.getAccessToken());
        result.put("expires_in", newAccessToken.getExpiresIn());
        result.put("refresh_token", refreshTokenStr);
    }catch (Exception e){
        result.put("code", 500);
        result.put("msg", "Refresh Token不可用!");

        logger.error("Refresh Token不可用",e);
    }

    return result;
}
复制代码

权限校验注解示例:

基本用法跟MVC项目开发模式同样,可是不一样的地方有两点。第一是请求接口的时候须要传递Access Token,默认支持如下三种方式传参(规则定义在cn/zifangsky/easylimit/session/impl/support/TokenWebSessionManager.javagetAccessTokenFromRequest()方法):

  • url参数或者form-data参数中携带了Access Token(其名称在上述配置的TokenInfo类中定义)
  • header参数中携带了Access Token(其名称同上)
  • header参数中携带了Access Token(其名称为Authorization

AccessToken传参示例

第二是在没有要求的角色/权限时,系统只会返回对应的状态码和提示信息,而不会像在MVC项目开发模式那样直接重定向到登陆页面。示例接口以及返回的错误提示以下所示:

@ResponseBody
@RequiresPermissions("/aaa/bbb")
@RequestMapping(value = "/selectByUsername", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public SysUser selectByUsername(String username) {
    return testService.selectByUsername(username);
}
复制代码

错误提示信息:

{
    "name": "no_permissions",
    "msg": "您当前没有权限访问该地址!",
    "code": 403
}
复制代码

核心流程

接下来我再简单介绍两个框架运行过程当中的核心流程,包括:请求API接口执行流程以及经过refresh_toke刷新access_token流程。

(1)请求API接口执行流程

  1. 首先尝试从HttpServletRequest中获取access_token,好比先从请求参数中获取,若是请求参数中不存在则继续尝试从Header中获取;
  2. 当从HttpServletRequest中获取到access_token后,继续尝试从从TokenDAO获取SimpleAccessToken对象。若是不存在或者已通过期,就给用户返回错误提示信息,流程到此结束。相反,能够获取而且校验没有过时,那么就从SimpleAccessToken对象中取出关联的session_id;
  3. 最后经过上一步获取的session_id从SessionDAO获取Session,此Session即为当前用户的会话信息,包含了当前访问用户拥有的角色、权限等基本信息。

进一步地,在建立完session后,为了方便在请求过程当中调用框架暴露出来的对外功能,所以还须要为当前请求建立请求实例。所以,所述建立当前访问实例(access)的步骤具体表现为:

  1. 建立TokenAccessContext对象,设置当前访问实例的环境环境,好比:session、access_token、ServletRequest、ServletResponse等信息;
  2. 调用TokenAccessFactory类的工厂方法建立Access实例;
  3. 将Session、登陆状态、用户主体信息(未登陆时不存在)绑定到Access。

至此,在建立完session和access后,filter模块将根据预先设置的规则校验当前请求接口是否须要登陆才能访问、是否须要拥有指定角色或者权限才能访问。所以,filter模块的鉴权逻辑步骤具体表现为:

  1. 若是当前用户已经登陆,那么继续执行鉴权逻辑,若是用户拥有访问该接口的指定角色/权限,那么继续执行接口的正常业务逻辑,反之给用户返回没有角色/权限的错误提示信息;
  2. 若是当前用户没有登陆,可是用户访问的接口须要登陆后才能访问,那么直接给用户返回错误提示信息;
  3. 若是当前用户没有登陆且不须要登陆就能够访问该接口,那么这个时候能够根据业务实际状况是否执行登陆操做,若是不登陆那么继续执行接口的正常业务逻辑,反之将调用Access.login(...)方法能够进行登陆操做。

请求API接口执行流程

(2)经过refresh_toke刷新access_token流程:

在这个流程中,除了统一执行的建立用户会话 (Session)和建立当前请求实例(Access)这两个步骤,还须要进行如下操做:

  1. 尝试使用refresh_token从TokenDAO获取SimpleRefreshToken对象;
  2. 若是获取失败或者通过判断refresh_token已通过期,那么给用户返回相应的错误提示,若是获取成功并且在有效期内,那么再判断当前用户是否已经登陆;
  3. 若是没有登陆,则须要使用SimpleRefreshToken中携带的用户基本信息调用登陆接口,完成自动登陆;
  4. 紧接着经过TokenOperateResolver对象生成新的access_token和refresh_token,并从TokenDAO中移除旧的access_token;
  5. 将新生成的access_token和refresh_token更新到TokenDAO,并绑定到Access
  6. 给用户返回新生成的access_token、refresh_token、过时时间等信息,至此该流程结束。

刷新access_token的流程

最后,若是判断某个用户帐号存在风险,管理人员也能够在管理端系统强制该用户下线。大致上须要执行如下几个步骤:

  1. 经过登陆用户名从TokenDAO中查找出该用户关联的access_token;
  2. 将该access_token以及对应的refresh_token的过时标识expired设置为true;
  3. 那么当某个用户使用该access_token再次请求系统时,服务端将会从TokenDAO中查找到该access_token关联的SimpleAccessToken对象的状态已经被设置为“过时”,所以禁止用户访问,并返回错误提示信息,也就实现了让用户强制下线的目的。

好了,限于篇幅,本篇文章到此就结束了,更多用法以及功能扩展方式,能够继续查看下面这几个连接:

相关文章
相关标签/搜索