開源我去年寫的適用於先後端分離項目的權限控制框架——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對象的狀態已經被設置爲「過時」,所以禁止用戶訪問,並返回錯誤提示信息,也就實現了讓用戶強制下線的目的。

好了,限於篇幅,本篇文章到此就結束了,更多用法以及功能擴展方式,能夠繼續查看下面這幾個連接:

相關文章
相關標籤/搜索