开启掘金成长之旅!这是我参与「掘金日新计划 · 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 系统拦截器,负责用户登录,并生成令牌。
希望今天的内容能对你有所帮助。