基于SpringBoot+Shiro+JWT实现的SSO单点登录之核心源码分析

215 阅读7分钟

基于SpringBoot+Shiro+JWT实现的SSO单点登录系统介绍

Shiro认证与授权机制源码分析

项目地址:

gitee:springboot-ucan-admin-sso单点登录系统

github:springboot-ucan-admin-sso 单点登录系统

项目结构

项目基本结构

Snipaste_2024-10-05_11-23-44.png

子项目目录结构

Snipaste_2024-10-05_11-25-45.png

认证流程代码解析

用户访问系统,会根据ShiroConfig#shiroFilterFactory中的配置而被Shiro过滤器拦截(以app1为例)。

com.ucan.app1.config.shiro.ShiroConfig#shiroFilterFactory

@Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilterFactory(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        shiroFilterFactoryBean.setLoginUrl("/toLogin");
        shiroFilterFactoryBean.setSuccessUrl("/index");
        shiroFilterFactoryBean.setUnauthorizedUrl("/toLogin");
        Map<String, Filter> filters = new LinkedHashMap<String, Filter>();
        JwtAuthenticatingFilter jwtAuthenticatingFilter = new JwtAuthenticatingFilter();
        jwtAuthenticatingFilter.setTokenCookieManager(tokenCookieManager);
        jwtAuthenticatingFilter.setTokenCookieMaxAge(tokenCookieMaxAge);
        filters.put("jwtAuth", jwtAuthenticatingFilter);
        shiroFilterFactoryBean.setFilters(filters);
 
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        filterChainDefinitionMap.put("/addToken", "anon");
        filterChainDefinitionMap.put("/pass", "anon");
//        filterChainDefinitionMap.put("/toLogin", "anon");
//        filterChainDefinitionMap.put("/toLogin.do", "anon");
        filterChainDefinitionMap.put("/login", "anon");
        filterChainDefinitionMap.put("/login.do", "anon");
        filterChainDefinitionMap.put("/logout", "anon");
        filterChainDefinitionMap.put("/logout.do", "anon");
        filterChainDefinitionMap.put("/css/**", "anon");
        filterChainDefinitionMap.put("/js/**", "anon");
        filterChainDefinitionMap.put("/imgs/**", "anon");
        filterChainDefinitionMap.put("/**", "jwtAuth,authc");// jwtAuth authc
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

如果当前域下的tokenCookie为空,则不论是登录页面的请求还是其他请求,都会重定向到 /pass;

tokenCookie不为空的情况

请求url不为登录页面地址

如果tokenCookie不为空,且请求url不为登录页面地址的时候,通过executeLogin 进行登录认证。

com.ucan.app1.shiro.filter.JwtAuthenticatingFilter#isAccessAllowed

/**
     * 通过发送 jwt token到sso认证系统来决定是否放行
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        // 从客户端获取jwt token
//        String token = getAuthzHeader(request);
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        String fromLogout=request.getParameter("fromLogout");
        Cookie[] cookies = req.getCookies();
        String token = "";
        if (!Objects.isNull(cookies) && cookies.length > 0) {
            for (int i = 0; i < cookies.length; i++) {
                if (cookies[i].getName().equals("tokenCookie")) {
                    token = cookies[i].getValue();
                    break;
                }
            }
        }
 
        if (!Objects.isNull(token) && !token.equals("")) {
            /**
             * 认证操作交给 {@code JwtRealm}{@link doGetAuthenticationInfo(AuthenticationToken
             * jwtToken) } 完成
             */
            try {
                boolean isLoginUrl = pathsMatch(getLoginUrl(), request);
                
                if (isLoginUrl) {// 如果是请求的是登录页面且token不为空,则交由/pass处理请求,进行页面token的验证与添加
                    resp.sendRedirect("/pass?fromLogout="+fromLogout);
                    return false;
                }
                return executeLogin(request, response);
            } catch (Exception e) {
                e.printStackTrace();
            }
 
        } else {
            try {
                resp.sendRedirect("/pass");
                return false;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        // 如果token==null,不做token校验,交由下一个过滤器进行处理
        return true;
    }

com.ucan.app1.shiro.filter.JwtAuthenticatingFilter#executeLogin

/**
     * 验证access token,如果认证失败,则会继续认证refresh token并尝试返回新的access token.<br>
     * 认证操作交给 {@code JwtRealm}{@link doGetAuthenticationInfo(AuthenticationToken
     * jwtToken) } 完成。<br>
     * 如果认证失败,将会抛出AuthenticationException异常
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        AuthenticationToken jwtToken = createToken(request, response);
        try {
            // 完成token验证、当前subject的principal、认证信息的填充
            Subject subject = getSubject(request, response);
            subject.login(jwtToken);
            Session session = subject.getSession(false);
            String newAccessToken = (String) session.getAttribute("newAccessToken");
            // 旧的access token失效,且可以refresh token验证通过且可以生成新的access token,
            // 则要更新tokenCookie
            if (!Objects.isNull(newAccessToken) && !newAccessToken.equals("")) {
                tokenCookieManager.setTokenCookie(newAccessToken, tokenCookieMaxAge, req, resp);
            }
        } catch (Exception e) {
         
            response.setContentType("text/html");
            String htmlContent = "<script>" + "setTimeout(function() {" + // 退出系统
                    "window.location.href =" + req.getContextPath() + "'/logout';" + "} , 100);" + "</script>";
            response.getWriter().write(htmlContent);
            return false;
        }
        return true;
    }

executeLogin 会调用 subject.login(jwtToken) ,从间接调用JwtRealm#doGetAuthenticationInfo进行accessToken的校验或者生成新的accessToken,最终返回认证状态,完成登录操作,跳转到目标页面。

com.ucan.app1.shiro.realm.JwtRealm#doGetAuthenticationInfo

/**
     * token 认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken jwtToken) throws AuthenticationException {
        String token = (String) jwtToken.getPrincipal();
        /**
         * 验证客户端access token,如果验证失败,则去验证该用户的refresh token refresh token
         * 验证成功后会尝试返回新access token
         */
        String result = tokenUtil.verifyAccessToken(token);
        JSONObject jsonObject = JSONObject.parseObject(result);
        Integer code = jsonObject.getInteger("code");
        if (code == 0) {// token验证成功(包括通过refresh token协助的认证)
            String newAccessToken = jsonObject.getString("data");
            if (!Objects.isNull(newAccessToken) && !newAccessToken.equals("")) {// 说明旧token验证失败,有新token生成
                JwtAuthenticationInfo jwtAuthenticationInfo = new JwtAuthenticationInfo(newAccessToken, newAccessToken,
                        getName());
                jwtAuthenticationInfo.setNewAccessToken(newAccessToken);
                return jwtAuthenticationInfo;
            } else {// 旧token依然有效
                return new JwtAuthenticationInfo(token, token, getName());
            }
        } else {
            throw new AuthenticationException(jsonObject.getString("msg"));
        }
    }
请求url为登录页面地址

如果tokenCookie不为空,且请求url为登录页面地址的时候,重定向到 /pass,跳转到 pass.ftl 页面,再发送请求到认证中心的 /jump ,去验证认证中心域名在当前浏览器下的tokenCookie的有效性。

<html>
<head>
<meta charset="UTF-8">
<meta name="viewport"
	content="width=device-width, initial-scale=1, maximum-scale=1">
<title>处理中...</title>
</head>
<body>
 
	<input type="hidden" id="ssoServerUrlInput" value="${ssoServerUrl}">
 
	<script src="js/jquery-3.6.3.min.js"></script>
 
	<script type="text/javascript">
		
	    //获取当前页面url
                        const currentURL = window.location.href;
                        var protocol = getProtocol(currentURL);
                        var rootDomain = getRootDomain(currentURL);
                        var port = getPort(currentURL);
                        var url = '';
                        if (port == "") {//组装目标url
                            url = protocol + '://' + rootDomain;
                        } else {
                            url = protocol + '://' + rootDomain + ":" + port;
 
                        }
                        
                        const urlParams = new URLSearchParams(window.location.search);
                        const fromLogout = urlParams.get('fromLogout');
                        const ssoServerUrl = document.getElementById('ssoServerUrlInput').value;
//发送请求到认证中心的 /jump ,去验证认证中心域名在当前浏览器下的tokenCookie的有效性
                        window.location.href = ssoServerUrl + "/jump?target="+encodeURIComponent(url)+"&fromLogout="+fromLogout;
	   /**
 * 获取协议
 */
function getProtocol(url) {
    var urlObj = new URL(url);
    var protocol = urlObj.protocol.slice(0, -1); // 去除最后的 ':'
    return protocol;
 
}
/**
 * 获取根域名
 */
function getRootDomain(url) {
    var urlObj = new URL(url);
    var host = urlObj.host;
    // 假设根域名不带 www 等前缀,找到第一个点之后的部分并截取其前面的内容作为根域名
    var firstDotIndex = host.indexOf('.');
    var secondDotIndex = host.indexOf('.', firstDotIndex + 1);
    var colonIndex = host.indexOf(':');
    var rootDomain = '';
    if(colonIndex==-1){
        rootDomain = secondDotIndex !== -1 ? host.substring(firstDotIndex+1) : host;
    }else{
        rootDomain = secondDotIndex !== -1 ? host.substring(firstDotIndex+1,colonIndex) : host.substring(0,colonIndex);
    }
    return rootDomain;
}
/**
 * 获取端口号
 */
function getPort(url) {
    var urlObj = new URL(url);
    var port = urlObj.port;
    return port;
}
	   
	</script>
</body>
</html>

如果sso域下的tokenCookie中的access token有效,则将token追加到当前发送登录认证请求的系统的 /addToken 的accessToken 参数中;否则进一步验证当前token的refresh token来决定是否要生成新的access token 还是跳转到sso登录页面,具体看以下代码注释。

com.ucan.sso.server.controller.sso.SsoServerController#jump

/**
     * 跨域验证与同步
     * 
     * @param username
     * @param password
     * @param fromLogout 是否是退出登录转发过来的请求: "1" 是 ,其他值 不是
     * @return
     */
    @RequestMapping("/jump")
//    @ResponseBody
    public String jump(@RequestParam("target") String target,
            @RequestParam(name = "fromLogout", required = false) String fromLogout, HttpServletRequest request,
            HttpServletResponse response) throws Exception {
//拼接.ucan.com 域下的cookie到http://www.umall.com/add?xxxx中,然后对该地址发起请求,设置.umall.com域下的cookie
        Cookie[] cookies = request.getCookies();
        String token = "";
        String protocol = "";
        String rootDomain = "";
        int port = -1;
        if (!Objects.isNull(cookies) && cookies.length > 0) {
            for (int i = 0; i < cookies.length; i++) {
                if (cookies[i].getName().equals("tokenCookie")) {
                    token = cookies[i].getValue();
                    break;
                }
            }
        }
        // 1.验证tokenCookie中的accessToken,如果验证成功,拼接accessToken至target目标url/addToken?accessToken=accessToken,然后进行重定向
        // 2.如果验证失败,则获取该用户的refreshToken,并尝试生成新的accessToken
        // 3.accessToken生成成功,更新.ucan.com域的tokenCookie,拼接accessToken至target目标url/addToken?accessToken=accessToken,然后进行重定向
        if (token.equals("")) {// 如果accessToken为空,则直接跳转到sso登录页面
            return "redirect:/toLogin?target=" + URLEncoder.encode(target, "UTF-8");
        } else {// 子系统进行logOut操作之后跳转的 /toLogin 会被拦截而转发到 /pass ,最后会转发到 SSO 的/jump
            if (!Objects.isNull(fromLogout) && fromLogout.equals("1")) {
                // 删除浏览器中SSO域下的tokenCookie,直接跳转到SSO登录页面,不用再进行token的校验
                tokenCookieManager.setTokenCookie("del", 0, request, response);
                return "redirect:/toLogin?target=" + URLEncoder.encode(target, "UTF-8");
            }
            boolean verifyJWT = false;
            if (!Objects.isNull(target) && !target.equals("")) {// 从target字符串中获取域名,用于设置cookie的域
                protocol = DomainUtil.getProtocol(target);
                rootDomain = DomainUtil.getRootDomain(target);
                port = DomainUtil.getPort(target);
            }
            try {
                // 验证accessToken
                verifyJWT = jwtUtil.verifyJWT(token);
            } catch (CustomException e) {
                log.error(e.getMessage());
                // 旧accessToken验证失败,尝试去获取对应用户的refreshToken,并尝试生成新的accessToken
                // 从旧accessToken中解析userName
                JSONObject payload = JwtBase64Util.getPayload(token);
                String userName = payload.getString("userName");
                // 获取对应用户的refreshToken
                Cache<String, String> refreshTokenCache = redisCacheManager.getCache("refreshToken");
                String refreshToken = refreshTokenCache.get(userName);
                String newAccessToken = "";
                try {
                    // 成功生成新的accessToken,更新sso系统的tokenCookie,拼接accessToken,重定向
                    newAccessToken = jwtUtil.updateAccessToken(refreshToken);
                    tokenCookieManager.setTokenCookie(newAccessToken, 86400, request, response);
                    String redirectUrl = protocol + "://" + rootDomain + (port != -1 ? (":" + port) : "")
                            + "/addToken?accessToken=" + newAccessToken + "&target=" + URLEncoder
                                    .encode(protocol + "://" + rootDomain + (port != -1 ? (":" + port) : ""), "UTF-8");
                    return "redirect:" + redirectUrl;
                } catch (CustomException e1) {
                    log.error(e1.getMessage());
                    return "redirect:/toLogin?target=" + URLEncoder.encode(target, "UTF-8");
                }
            }
 
            if (verifyJWT) {// 旧的accessToken验证成功,拼接accessToken,重定向
                String redirectUrl = protocol + "://" + rootDomain + (port != -1 ? (":" + port) : "")
                        + "/addToken?accessToken=" + token + "&target="
                        + URLEncoder.encode(protocol + "://" + rootDomain + (port != -1 ? (":" + port) : ""), "UTF-8");
                return "redirect:" + redirectUrl;
            } else {// 旧accessToken验证失败,尝试去获取对应用户的refreshToken,并尝试生成新的accessToken
                // 从旧accessToken中解析userName
                JSONObject payload = JwtBase64Util.getPayload(token);
                String userName = payload.getString("userName");
                // 获取对应用户的refreshToken
                Cache<String, String> refreshTokenCache = redisCacheManager.getCache("refreshToken");
                String refreshToken = refreshTokenCache.get(userName);
                String newAccessToken = "";
                try {// 成功生成新的accessToken,更新sso系统的tokenCookie,拼接accessToken,重定向
                    newAccessToken = jwtUtil.updateAccessToken(refreshToken);
                    tokenCookieManager.setTokenCookie(newAccessToken, 86400, request, response);
                    String redirectUrl = protocol + "://" + rootDomain + (port != -1 ? (":" + port) : "")
                            + "/addToken?accessToken=" + newAccessToken + "&target=" + URLEncoder
                                    .encode(protocol + "://" + rootDomain + (port != -1 ? (":" + port) : ""), "UTF-8");
                    return "redirect:" + redirectUrl;
                } catch (CustomException e) {
                    log.error(e.getMessage());
                    return "redirect:/toLogin?target=" + URLEncoder.encode(target, "UTF-8");
                }
            }
        }
    }

com.ucan.app1.controller.system.login.LoginController#addToken

/**
     * 设置当前域名下的tokenCookie
     * 
     * @param accessToken
     * @param target
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/addToken")
    public String addToken(@RequestParam("accessToken") String accessToken,
            @RequestParam(name = "target", required = false) String target, HttpServletRequest request,
            HttpServletResponse response) {
        // 设置cookie, 有效期:1天,-1:浏览器关闭 立即清除
        tokenCookieManager.setTokenCookie(accessToken, 86400, request, response);
        // 重定向到/index,尝试登录认证
        return "redirect:/index";
 
    }

完成当前域的tokenCookie的设置与token认证,返回认证信息,完成登录操作,跳转到 index页面。

tokenCookie为空的情况

不管是不是请求登录页面,只要系统有这个页面存在,都会被shiro过滤器拦截,重定向至 /pass页面,然后发送请求到SSO系统的 /jump 来验证 当前浏览器中SSO认证系统域下的tokenCookie中的access token 或 refresh token ,以决定是否可以生成新的access token来完成子系统单点登录还是跳转到SSO的登录页面,让用户重新登录来完成认证操作。

认证流程图

sso-login.png

基于SpringBoot+Shiro+JWT实现的SSO单点登录系统介绍

欢迎交流学习!