天天看點

Shiro實作多realm方案

公司開始了新項目,新項目的認證采用的是Shiro實作。由于涉及到多端登入使用者,而且多端使用者還是來自不同的表。

這就涉及到了Shiro的多realm,今天的demo主要是介紹Shiro的多realm實作方案,文中包含所有的代碼,需要的朋友可以無縫copy。

大家好,我是程式員田同學。

公司開始了新項目,新項目的認證采用的是Shiro實作。由于涉及到多端登入使用者,而且多端使用者還是來自不同的表。

這就涉及到了Shiro的多realm,今天的demo主要是介紹Shiro的多realm實作方案,文中包含所有的代碼,需要的朋友可以無縫copy。

Shiro實作多realm方案

前後端分離的背景下,在認證的實作中主要是兩方面的内容,一個是使用者登入擷取到token,二是從請求頭中拿到token并檢驗token的有效性和設定緩存。

1、使用者登入擷取token

登入和以往單realm實作邏輯一樣,使用使用者和密碼生成token傳回給前端,前端每次請求接口的時候攜帶token。

@ApiOperation(value="登入", notes="登入")
    public Result<JSONObject> wxappLogin(String username,String password){
     Result<JSONObject> result = new Result<JSONObject>();
        JSONObject obj = new JSONObject();
	// 生成token
	String password="0";
	String token = JwtUtil.sign(username, password);
        obj.put("token", token);
        result.setResult(obj);
        result.success("登入成功");

        return result;
        }	
           

生成token的工具類

/**
 * 生成簽名,5min後過期
 *
 * @param username 使用者名
 * @param secret   使用者的密碼
 * @return 加密的token
 */
public static String sign(String username, String secret) {
   Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
   Algorithm algorithm = Algorithm.HMAC256(secret);
   // 附帶username資訊
   return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);
}
           

以上就實作了簡單的登入邏輯,和Shiro的單realm設定和SpringSecurity的登入邏輯都沒有什麼差別。

2、鑒權登入攔截器(驗證token有效性)

使用Shiro登入攔截器的隻需要繼承Shiro的 BasicHttpAuthenticationFilter 類 重寫 isAccessAllowed()方法,在該方法中我們從ServletRequest中擷取到token和login_type。

需要特别指出的是,由于是多realm,我們在請求頭中加入一個login_type來區分不同的登入類型。

通過token和login_type我們生成一個JwtToken對象送出給getSubject。

JwtFilter過濾器

@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {

    /**
     * 預設開啟跨域設定(使用單體)
     */
    private boolean allowOrigin = true;

    public JwtFilter(){}
    public JwtFilter(boolean allowOrigin){
        this.allowOrigin = allowOrigin;
    }

    /**
     * 執行登入認證
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            executeLogin(request, response);
            return true;
        } catch (Exception e) {
            JwtUtil.responseError(response,401,CommonConstant.TOKEN_IS_INVALID_MSG);
            return false;
            //throw new AuthenticationException("Token失效,請重新登入", e);
        }
    }

    /**
     *
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader(CommonConstant.X_ACCESS_TOKEN);
        String loginType = httpServletRequest.getHeader(CommonConstant.LOGIN_TYPE);
        // update-begin--Author:lvdandan Date:20210105 for:JT-355 OA聊天添加token驗證,擷取token參數
        if (oConvertUtils.isEmpty(token)) {
            token = httpServletRequest.getParameter("token");
        }
        // update-end--Author:lvdandan Date:20210105 for:JT-355 OA聊天添加token驗證,擷取token參數

        JwtToken jwtToken = new JwtToken(token,loginType);
        // 送出給realm進行登入,如果錯誤他會抛出異常并被捕獲
        getSubject(request, response).login(jwtToken);
        // 如果沒有抛出異常則代表登入成功,傳回true
        return true;
    }
  
}
           

JwtToken類

public class JwtToken implements AuthenticationToken {
   
   private static final long serialVersionUID = 1L;
   private String token;

    private String loginType;
//    public JwtToken(String token) {
//        this.token = token;
//    }
    public JwtToken(String token,String loginType) {
        this.token = token;
        this.loginType=loginType;
    }
    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public String getLoginType() {
        return loginType;
    }

    public void setLoginType(String loginType) {
        this.loginType = loginType;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }
 
    @Override
    public Object getCredentials() {
        return token;
    }
}
           

再往下的邏輯肯定會先根據我們的login_type來走不同的realm了,然後在各自的realm中去檢查token的有效性了,那Shiro怎麼知道我們的Realm都是哪些呢?

接下來就該引出使用Shiro的核心配置檔案了——ShiroConfig.java類

shiro的配置檔案中會注入名字為securityManager的Bean。

在該bean中首先注入ModularRealmAuthenticator,ModularRealmAuthenticator會根據配置的AuthenticationStrategy(身份驗證政策)進行多Realm認證過程。

由于是多realm我們需要重寫ModularRealmAuthenticator類,ModularRealmAuthenticator類中用于判斷邏輯走不同的realm,接着注入我們的兩個realm,分别是myRealm和clientShiroRealm。

重新注入 ModularRealm類

@Bean
    public ModularRealm ModularRealm(){
        //自己重寫的ModularRealmAuthenticator
        ModularRealm modularRealm = new ModularRealm();
//        modularRealmAuthenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy());//這裡為預設政策:如果有一個或多個Realm驗證成功,所有的嘗試都被認為是成功的,如果沒有一個驗證成功,則該次嘗試失敗
        return modularRealm;
    }
           

securityManager-bean。

@Bean("securityManager")
    public DefaultWebSecurityManager securityManager(ShiroRealm myRealm,
    ClientShiroRealm clientShiroRealm,ModularRealm modularRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//        securityManager.setRealm(myRealm);

        securityManager.setAuthenticator(modularRealm);
        List<Realm> realms = new ArrayList<>();
        //添加多個Realm
        realms.add(myRealm);
        realms.add(clientShiroRealm);
        securityManager.setRealms(realms);

        /*
         * 關閉shiro自帶的session,詳情見文檔
         * http://shiro.apache.org/session-management.html#SessionManagement-
         * StatelessApplications%28Sessionless%29
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        //自定義緩存實作,使用redis
        securityManager.setCacheManager(redisCacheManager());
        return securityManager;
    }
           

ModularRealm實作類

public class ModularRealm  extends ModularRealmAuthenticator {

    @Override
    protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        assertRealmsConfigured();
        Collection<Realm> realms = getRealms();
        // 登入類型對應的所有Realm
        HashMap<String, Realm> realmHashMap = new HashMap<>(realms.size());

        for (Realm realm : realms) {
            // 這裡使用的realm中定義的Name屬性來進行區分,注意realm中要加上
            realmHashMap.put(realm.getName(), realm);
        }

        JwtToken token = (JwtToken) authenticationToken;

        if (StrUtil.isEmpty(token.getLoginType())){
            return  doSingleRealmAuthentication(realmHashMap.get(LoginType.DEFAULT.getType()),token);
        } else {
            return  doSingleRealmAuthentication(realmHashMap.get(token.getLoginType()),token);
        }

//        return super.doAuthenticate(authenticationToken);
    }
}
           

然後會根據不同的login_type到不同的realm,下面為我的Shiro認證realm。

myrealm類.

@Component
@Slf4j
public class ShiroRealm extends AuthorizingRealm {
   @Lazy
    @Resource
    private CommonAPI commonApi;

    @Lazy
    @Resource
    private RedisUtil redisUtil;

    @Override
    public String getName() {
        return LoginType.DEFAULT.getType();
    }
    /**
     * 必須重寫此方法,不然Shiro會報錯
     */
    @Override
    public boolean supports(AuthenticationToken token) {
//        return token instanceof JwtToken;
        if (token instanceof JwtToken){
            return StrUtil.isEmpty(((JwtToken) token).getLoginType()) || LoginType.CLIENT.getType().equals(((JwtToken) token).getLoginType());
        } else {
            return false;
        }
    }

    /**
     * 權限資訊認證(包括角色以及權限)是使用者通路controller的時候才進行驗證(redis存儲的此處權限資訊)
     * 觸發檢測使用者權限時才會調用此方法,例如checkRole,checkPermission
     *
     * @param principals 身份資訊
     * @return AuthorizationInfo 權限資訊
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.debug("===============Shiro權限認證開始============ [ roles、permissions]==========");
        String username = null;
        if (principals != null) {
            LoginUser sysUser = (LoginUser) principals.getPrimaryPrincipal();
            username = sysUser.getUsername();
        }
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();

        // 設定使用者擁有的角色集合,比如“admin,test”
        Set<String> roleSet = commonApi.queryUserRoles(username);
        System.out.println(roleSet.toString());
        info.setRoles(roleSet);

        // 設定使用者擁有的權限集合,比如“sys:role:add,sys:user:add”
        Set<String> permissionSet = commonApi.queryUserAuths(username);
        info.addStringPermissions(permissionSet);
        System.out.println(permissionSet);
        log.info("===============Shiro權限認證成功==============");
        return info;
    }

    /**
     * 使用者資訊認證是在使用者進行登入的時候進行驗證(不存redis)
     * 也就是說驗證使用者輸入的賬号和密碼是否正确,錯誤抛出異常
     *
     * @param auth 使用者登入的賬号密碼資訊
     * @return 傳回封裝了使用者資訊的 AuthenticationInfo 執行個體
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        log.debug("===============Shiro身份認證開始============doGetAuthenticationInfo==========");
        String token = (String) auth.getCredentials();
        if (token == null) {
            HttpServletRequest req = SpringContextUtils.getHttpServletRequest();
            log.info("————————身份認證失敗——————————IP位址:  "+ oConvertUtils.getIpAddrByRequest(req) +",URL:"+req.getRequestURI());
            throw new AuthenticationException("token為空!");
        }
        // 校驗token有效性
        LoginUser loginUser = null;
        try {
            loginUser = this.checkUserTokenIsEffect(token);
        } catch (AuthenticationException e) {
            JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(),401,e.getMessage());
            e.printStackTrace();
            return null;
        }
        return new SimpleAuthenticationInfo(loginUser, token, getName());
    }

    /**
     * 校驗token的有效性
     *
     * @param token
     */
    public LoginUser checkUserTokenIsEffect(String token) throws AuthenticationException {
        // 解密獲得username,用于和資料庫進行對比
        String username = JwtUtil.getUsername(token);
        if (username == null) {
            throw new AuthenticationException("token非法無效!");
        }

        // 查詢使用者資訊
        log.debug("———校驗token是否有效————checkUserTokenIsEffect——————— "+ token);
        LoginUser loginUser = TokenUtils.getLoginUser(username,commonApi,redisUtil);
        //LoginUser loginUser = commonApi.getUserByName(username);
        if (loginUser == null) {
            throw new AuthenticationException("使用者不存在!");
        }
        // 判斷使用者狀态
        if (loginUser.getStatus() != 1) {
            throw new AuthenticationException("賬号已被鎖定,請聯系管理者!");
        }
        // 校驗token是否逾時失效 & 或者賬号密碼是否錯誤
        if (!jwtTokenRefresh(token, username, loginUser.getPassword())) {
            throw new AuthenticationException(CommonConstant.TOKEN_IS_INVALID_MSG);
        }
        //update-begin-author:taoyan date:20210609 for:校驗使用者的tenant_id和前端傳過來的是否一緻
        String userTenantIds = loginUser.getRelTenantIds();
        if(oConvertUtils.isNotEmpty(userTenantIds)){
            String contextTenantId = TenantContext.getTenant();
            String str ="0";
            if(oConvertUtils.isNotEmpty(contextTenantId) && !str.equals(contextTenantId)){
                //update-begin-author:taoyan date:20211227 for: /issues/I4O14W 使用者租戶資訊變更判斷漏洞
                String[] arr = userTenantIds.split(",");
                if(!oConvertUtils.isIn(contextTenantId, arr)){
                    throw new AuthenticationException("使用者租戶資訊變更,請重新登陸!");
                }
                //update-end-author:taoyan date:20211227 for: /issues/I4O14W 使用者租戶資訊變更判斷漏洞
            }
        }
        //update-end-author:taoyan date:20210609 for:校驗使用者的tenant_id和前端傳過來的是否一緻
        return loginUser;
    }

    /**
     * JWTToken重新整理生命周期 (實作: 使用者線上操作不掉線功能)
     * 1、登入成功後将使用者的JWT生成的Token作為k、v存儲到cache緩存裡面(這時候k、v值一樣),緩存有效期設定為Jwt有效時間的2倍
     * 2、當該使用者再次請求時,通過JWTFilter層層校驗之後會進入到doGetAuthenticationInfo進行身份驗證
     * 3、當該使用者這次請求jwt生成的token值已經逾時,但該token對應cache中的k還是存在,則表示該使用者一直在操作隻是JWT的token失效了,程式會給token對應的k映射的v值重新生成JWTToken并覆寫v值,該緩存生命周期重新計算
     * 4、當該使用者這次請求jwt在生成的token值已經逾時,并在cache中不存在對應的k,則表示該使用者賬戶空閑逾時,傳回使用者資訊已失效,請重新登入。
     * 注意: 前端請求Header中設定Authorization保持不變,校驗有效性以緩存中的token為準。
     *       使用者過期時間 = Jwt有效時間 * 2。
     *
     * @param userName
     * @param passWord
     * @return
     */
    public boolean jwtTokenRefresh(String token, String userName, String passWord) {
        String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token));
        if (oConvertUtils.isNotEmpty(cacheToken)) {
            // 校驗token有效性
            if (!JwtUtil.verify(cacheToken, userName, passWord)) {
                //生成token
                String newAuthorization = JwtUtil.sign(userName, passWord);
                // 設定逾時時間
                redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization);
                redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME *2 / 1000);
                log.debug("——————————使用者線上操作,更新token保證不掉線—————————jwtTokenRefresh——————— "+ token);
            }
            //update-begin--Author:scott  Date:20191005  for:解決每次請求,都重寫redis中 token緩存問題
//       else {
//          // 設定逾時時間
//          redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, cacheToken);
//          redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME / 1000);
//       }
            //update-end--Author:scott  Date:20191005   for:解決每次請求,都重寫redis中 token緩存問題
            return true;
        }

        //redis中不存在此TOEKN,說明token非法傳回false
        return false;
    }

    /**
     * 清除目前使用者的權限認證緩存
     *
     * @param principals 權限資訊
     */
    @Override
    public void clearCache(PrincipalCollection principals) {
        super.clearCache(principals);
    }

}
           

ClientShiroRealm類.

@Component
@Slf4j
public class ClientShiroRealm extends AuthorizingRealm {
   @Lazy
    @Resource
    private ClientAPI clientAPI;

    @Lazy
    @Resource
    private RedisUtil redisUtil;
    @Override
    public String getName() {
        return LoginType.CLIENT.getType();
    }
    /**
     * 必須重寫此方法,不然Shiro會報錯
     */
    @Override
    public boolean supports(AuthenticationToken token) {
//        return token instanceof JwtToken;
        if (token instanceof JwtToken){
            return  LoginType.CLIENT.getType().equals(((JwtToken) token).getLoginType());
        } else {
            return false;
        }
    }

    /**
     * 權限資訊認證(包括角色以及權限)是使用者通路controller的時候才進行驗證(redis存儲的此處權限資訊)
     * 觸發檢測使用者權限時才會調用此方法,例如checkRole,checkPermission
     *
     * @param principals 身份資訊
     * @return AuthorizationInfo 權限資訊
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.debug("===============Shiro權限認證開始============ [ roles、permissions]==========");
        //String username = null;
        //if (principals != null) {
        //    LoginUser sysUser = (LoginUser) principals.getPrimaryPrincipal();
        //    username = sysUser.getUsername();
        //}
        //SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        //// 設定使用者擁有的角色集合,比如“admin,test”
        //Set<String> roleSet = commonApi.queryUserRoles(username);
        //System.out.println(roleSet.toString());
        //info.setRoles(roleSet);
        //
        //// 設定使用者擁有的權限集合,比如“sys:role:add,sys:user:add”
        //Set<String> permissionSet = commonApi.queryUserAuths(username);
        //info.addStringPermissions(permissionSet);
        //System.out.println(permissionSet);
        log.info("===============Shiro權限認證成功==============");
        return null;
    }

    /**
     * 使用者資訊認證是在使用者進行登入的時候進行驗證(不存redis)
     * 也就是說驗證使用者輸入的賬号和密碼是否正确,錯誤抛出異常
     *
     * @param auth 使用者登入的賬号密碼資訊
     * @return 傳回封裝了使用者資訊的 AuthenticationInfo 執行個體
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        log.debug("===============Shiro身份認證開始============doGetAuthenticationInfo==========");
        String token = (String) auth.getCredentials();
        if (token == null) {
            HttpServletRequest req = SpringContextUtils.getHttpServletRequest();
            log.info("————————身份認證失敗——————————IP位址:  "+ oConvertUtils.getIpAddrByRequest(req) +",URL:"+req.getRequestURI());
            throw new AuthenticationException("token為空!");
        }
        // 校驗token有效性
        LoginUser loginUser = null;
        try {
            loginUser = this.checkUserTokenIsEffect(token);
        } catch (AuthenticationException e) {
            JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(),401,e.getMessage());
            e.printStackTrace();
            return null;
        }
        return new SimpleAuthenticationInfo(loginUser, token, getName());
    }

    /**
     * 校驗token的有效性
     *
     * @param token
     */
    public LoginUser checkUserTokenIsEffect(String token) throws AuthenticationException {
        // 解密獲得username,用于和資料庫進行對比
        String username = JwtUtil.getUsername(token);
        if (username == null) {
            throw new AuthenticationException("token非法無效!");
        }

        // 查詢使用者資訊
        log.debug("———校驗token是否有效————checkUserTokenIsEffect——————— "+ token);
        LoginUser loginUser = TokenUtils.getClientLoginUser(username,clientAPI,redisUtil);
        //LoginUser loginUser = commonApi.getUserByName(username);
        if (loginUser == null) {
            throw new AuthenticationException("使用者不存在!");
        }

        // 校驗token是否逾時失效 & 或者賬号密碼是否錯誤
        if (!jwtTokenRefresh(token, username, loginUser.getPassword())) {
            throw new AuthenticationException(CommonConstant.TOKEN_IS_INVALID_MSG);
        }
        return loginUser;
    }

    /**
     * JWTToken重新整理生命周期 (實作: 使用者線上操作不掉線功能)
     * 1、登入成功後将使用者的JWT生成的Token作為k、v存儲到cache緩存裡面(這時候k、v值一樣),緩存有效期設定為Jwt有效時間的2倍
     * 2、當該使用者再次請求時,通過JWTFilter層層校驗之後會進入到doGetAuthenticationInfo進行身份驗證
     * 3、當該使用者這次請求jwt生成的token值已經逾時,但該token對應cache中的k還是存在,則表示該使用者一直在操作隻是JWT的token失效了,程式會給token對應的k映射的v值重新生成JWTToken并覆寫v值,該緩存生命周期重新計算
     * 4、當該使用者這次請求jwt在生成的token值已經逾時,并在cache中不存在對應的k,則表示該使用者賬戶空閑逾時,傳回使用者資訊已失效,請重新登入。
     * 注意: 前端請求Header中設定Authorization保持不變,校驗有效性以緩存中的token為準。
     *       使用者過期時間 = Jwt有效時間 * 2。
     *
     * @param userName
     * @param passWord
     * @return
     */
    public boolean jwtTokenRefresh(String token, String userName, String passWord) {
        String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token));
        if (oConvertUtils.isNotEmpty(cacheToken)) {
            // 校驗token有效性
            if (!JwtUtil.verify(cacheToken, userName, passWord)) {
                //生成token
                String newAuthorization = JwtUtil.sign(userName, passWord);
                // 設定逾時時間
                redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization);
                redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME *2 / 1000);
                log.debug("——————————使用者線上操作,更新token保證不掉線—————————jwtTokenRefresh——————— "+ token);
            }

            return true;
        }

        //redis中不存在此TOEKN,說明token非法傳回false
        return false;
    }

    /**
     * 清除目前使用者的權限認證緩存
     *
     * @param principals 權限資訊
     */
    @Override
    public void clearCache(PrincipalCollection principals) {
        super.clearCache(principals);
    }

}
           

這兩個realm更多的是需要實作我們自身的realm,我把我的全部代碼貼上,讀者可根據自己的需要進行修改,兩個方法大緻的作用都是檢驗token的有效性,隻是查詢的使用者從不同的使用者表中查出來的。

至此,Shiro的多Realm實作方案到這裡就正式結束了。