阅读 1058

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

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

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

开发背景

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

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

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

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

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

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

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

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

所谓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对象的状态已经被设置为“过期”,因此禁止用户访问,并返回错误提示信息,也就实现了让用户强制下线的目的。

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

关注下面的标签,发现更多相似文章
评论