Spring Boot「35」Shiro 中实现 SSO 登录拦截器

1,260 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 20 天,点击查看活动详情

在前面的文章 Spring Boot「34」SSO 单点登录实现中使用 Redis 存储会话 中,我介绍了如何在 Redis 中存储 SSO 登录流程中的全局会话、临时令牌。 今天将继续之前的内容,完成 SSO 登录流程中的最后一环,SSO 登录拦截器。

在介绍细节之前,先来回顾下之前介绍的业务流程 Spring Boot「28」扩展:SSO 单点登录流程分析。 在场景一和二中,流程涉及的模块有三个,用户、业务系统、CAS 系统。 业务系统中的拦截器职责为:检查用户是否已登录(检查方式是通过局部会话、令牌是否有效),若已登录则放行,未登录则重定向到 CAS 系统做登录。 CAS 系统中的拦截器职责为:检查用户是否已登录(检查方式是通过全局会话),若已登录,则携带着令牌信息重定向到业务系统;若未登录,要求用户登录,登录成功后重复前面已登录时的动作。

接下来,我将分别从业务系统和 CAS 系统的角度,介绍它们各自的拦截器实现。

01-业务系统拦截器实现

结合前面的分析,业务系统拦截器的职责比较简单。 我通过继承 Shiro 中的 AccessControlFilter 来实现过滤器,具体代码如下:

/**
 * 负责业务系统请求的拦截
 * 职责包括:
 * 1. 检查用户是否认证过(局部会话),若有,则放行;
 * 2. 若未认证,则检查是否有 auth code 且有效,有效则放行,无效则重定向到 CAS 登录界面
 * @author Samson
 */
public class ClientFilter extends AccessControlFilter {
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        final Subject subject = getSubject(request, response);
        return subject.isAuthenticated() || verifyAuthCode(request, response);
    }

    /**
     * 发现未认证后,跳转到 CAS 登录
     * @param request  the incoming <code>ServletRequest</code>
     * @param response the outgoing <code>ServletResponse</code>
     * @return
     * @throws Exception
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        String casUrl = "http://localhost:18886/shiro-web/login";
        final HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        String redirectUrl = String.format("%s?backurl=%s", casUrl, httpServletRequest.getRequestURI());
        WebUtils.issueRedirect(request, response, redirectUrl, null, false);
        return false;
    }

    /**
     * 检查 Subject 是否认证过
     * 注:只有局部会话未创建时,首次检查 auth code 时才会进入到这里
     * @param request
     * @param response
     * @return true:request 中包含 authCode,且经过 CAS 验证有效;
     *         false:其他情况
     */
    public boolean verifyAuthCode(ServletRequest request, ServletResponse response) {
        String casUrl = "http://localhost:18886/shiro-web/auth";
        final String authCode = request.getParameter("authCode");
        final String username = request.getParameter("username");

        if (!ObjectUtils.isEmpty(authCode) && !ObjectUtils.isEmpty(username)) {
            final int status = HttpRequest.post(casUrl)
                    .form(Collections.singletonMap("authCode", authCode))
                    .execute()
                    .getStatus();
            // 说明 auth code 验证通过了,subject 在 cas 登录过了
            if (status == HttpStatus.HTTP_OK) {
                // 创建局部会话
                final Subject subject = getSubject(request, response);
                // 这里需要搭配特殊的 realm 来实现
                // cas client 端中的登录不需要输入用户密码,只要 authcode 验证通过后,后续的身份认证通过 username + authCode
                subject.login(new UsernamePasswordToken(username, authCode));
                return true;
            }
        }

        // 请求中压根儿就没有 auth code,所以直接拒绝访问,走 onAccessDenied 逻辑
        return false;
    }
}

ClientFilter 中的逻辑主要集中在 AccessControlFilter 中定义的两个方法中。

  • isAccessAllowed 方法,主要定义拦截器是否允许请求继续向后处理。返回值为 true 时,意味着允许访问,否则为不允许访问。 在我们这个业务场景中,允许访问意味着用户已经登录(存在局部会话或携带的令牌有效,即已在 CAS 做过登录) 所以,我在该方法中的实现是:
    • subject.isAuthenticated(),存在局部会话
    • verifyAuthCode(request, response),不存在局部会话,需要验证是否携带令牌,及令牌是否有效。若令牌有效,则需要建立局部会话。
  • onAccessDenied 方法,主要在不允许访问时调用。 按照我们之前的分析,在不允许访问(即未登录时),需要将用户重定向到 CAS 系统去登录。 所以,我们在这里实现也比较简单,即将请求重定向到 CAS,并且用 backurl 标注登录成功后要返回的链接。

这里有点需要特别说明的是令牌(即 auth code)的验证过程。

02-令牌验证过程实现

令牌是用户已在 CAS 登录过的标识,能证明用户的身份。 用户在 CAS 登录时,除了生成了全局会话,还创建了一个令牌,称为 auth code,并通过重定向发送给客户端。 当用户携带者 auth code 访问业务系统时,业务系统需要根据 auth code 是否有效而选择是否允许用户访问,并创建局部会话。

业务系统可以通过 CAS 提供的接口验证令牌的有效性。

@PostMapping("/auth")
public void verify(HttpServletRequest request, HttpServletResponse response) {

    final String authCode = request.getParameter("authCode");
    
    final String authCodeKey = getAuthCodeKey(authCode);
    final String authCodeInRedis = redisTemplate.opsForValue().get(authCodeKey);

    // 如果 redis 中存在,且值与传进来的一样,则认为是验证成功,返回 200,否则返回 401
    if (authCodeInRedis != null && authCodeInRedis.equalsIgnoreCase(authCode)) {
        response.setStatus(HttpStatus.OK.value());
    } else {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
    }
}

有了上述接口,我们在业务系统中可以通过 HttpClient 去调用该接口,验证令牌的有效性。(代码贴在了上节。)

03-CAS 系统拦截器实现

前面两节中,我介绍了业务系统中使用的拦截器。 本节中,将介绍 CAS 中拦截器的实现。

CAS 拦截器的职责相对业务系统中更简单,只需要检查用户是否已登录,若未登录,则要求用户登录。 只不过,在用户登录成功后,需要重定向到原来的系统中。 我通过继承 Shiro 中的 FormAuthenticationFilter 实现了 CAS 系统中的拦截器。具体实现代码如下:

/**
 * 在 SSO 登录流程中,CAS 端的 Filter 来说,它的职责只有判断用户是否登录(全局会话)
 * 若未认证,则重定向到登录界面,并处理后续的登录请求
 *
 * Shiro 中提供的 FormAuthenticationFilter 恰好能满足 CAS 的需求,只不过在跳转的时候需要稍微定制化一下
 * @author Samson
 */
public class CasFilter extends FormAuthenticationFilter {

    /**
     * 登录成功后,跳回 backurl 并且附上 authCode & username
     *
     * @param request  the incoming request
     * @param response the outgoing response
     * @throws Exception
     */
    @Override
    protected void issueSuccessRedirect(ServletRequest request, ServletResponse response) throws Exception {
        // savedRequest 是重定向到 CAS 系统时的值,即业务系统发现用户未登录,重定向用户到 CAS 登录的 url
        // http://cas.example.samson.self/index?backurl=http://businessa.example.samson.self/users
        final SavedRequest savedRequest = WebUtils.getAndClearSavedRequest(request);
        if (savedRequest != null && savedRequest.getMethod().equalsIgnoreCase(AccessControlFilter.GET_METHOD)) {

            // 到达 issueSuccessRedirect 说明我们在 CAS 登录成功了,需要通过重定向回到 backurl 中的地址,并且附上 authCode
            final String queryString = savedRequest.getQueryString();
            // backurl=http://businessa.example.samson.self/users&xxx=xxx&xxxx,转换成 map 形式
            final Map<String, String> savedQueryMap = queryMap(queryString);  

            final String backurl = savedQueryMap.get("backurl");

            Subject subject = SecurityUtils.getSubject();
            Session session = subject.getSession();
            String authCode = (String) session.getAttribute("authCode");
            String username = (String) subject.getPrincipal();
            final Map<String, String> map = new HashMap<>(8);
            map.put("authCode", authCode);
            map.put("username", username);
            WebUtils.issueRedirect(request, response, backurl, map, false);
        } else {
            // 如果没有 savedRequest 说明我们直接访问的 /login 页面
            WebUtils.issueRedirect(request, response, getSuccessUrl(), null, true);
        }
    }
}

我们对 CasFilter 的诉求,与 Shiro 提供的 FormAuthenticationFilter 并无特别大的差别。 只是在登录成功后,重定向跳转上有点区别。 Shiro 的实现中,重定向到了之前保存的链接,或 successUrl 定义的链接。

我们需要在登录成功后,跳转到 backurl(指对业务系统的访问),并且需要将 auth code 和 username 一并传回。 到现在,SSO 登录中的所有内容就都拼齐了,可以运行一下试试了。 如果需要完整的代码,可以去我的 gitee.com 仓库下载。

04-总结

今天,我介绍了如何通过 Shiro 实现 SSO 拦截器。根据业务职责,拦截器分为两类。 第一类,业务系统端的拦截器,负责拦截用户对业务系统的直接访问,并重定向到 CAS 系统登录; 对于带令牌的访问,需要去 CAS 系统验证令牌有效性。 第二类,CAS 系统拦截器,负责用户登录,并生成令牌。

希望今天的内容能对你有所帮助。

refs