基于SpringBoot+Shiro+JWT实现的SSO单点登录系统介绍
项目地址:
gitee:springboot-ucan-admin-sso单点登录系统
github:springboot-ucan-admin-sso 单点登录系统
项目结构
项目基本结构
子项目目录结构
认证流程代码解析
用户访问系统,会根据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的登录页面,让用户重新登录来完成认证操作。
认证流程图
基于SpringBoot+Shiro+JWT实现的SSO单点登录系统介绍
欢迎交流学习!