超超不开心。
2022/03/08阅读:73主题:默认主题
带你了解SSO登录全过程
一天一个小知识(工程篇):带你了解单点登录(SSO)全过程
不知不觉,已经从学生转为社🐶已经八个月了。这八个月忙于公司里的工作,公众号停更了好久。 最近在组里参与一个技术项目,其中一个需求就是本文要介绍到的SSO登录,接下来我们一起来了解。
SSO的概述
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务
整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统,其主要应用于企业内部登录系统。
SSO实现机制
要实现SSO,需要以下主要的功能:
-
所有应用系统共享一个身份认证系统。统一认证系统是SSO的前提之一。认证系统的主要功能是将用户的登录信息和用户信息库相比较,对用户进行登录认证;认证成功后,认证系统应该生成统一的认证标志(ticket),返还给用户。另外,认证系统还应该对ticket进行效验,判断其有效性。
-
所有
应用系统
(业务系统)能够识别和提取ticket信息。要实现SSO的功能,让用户只登录一次,就必须让应用系统能够识别已经登录过的用户。应用系统
(业务系统)应该能对ticket进行识别和提取,通过与认证系统的通讯,能自动判断当前用户是否登录过,从而完成单点登录的功能。

如上图所示,图中有4个系统,分别是Application1、Application2、Application3、和SSO。其中,Application1、Application2、Application3没有登录模块,而SSO只有登录模块,没有其他的业务模块。而当Application1、Application2、Application3需要登录时,将统一跳转到SSO系统。随着SSO系统完成登录,其他的应用系统也就随之登录了。这样就符合单点登录(SSO)的定义。
-
实际例子 百度地图和百度贴吧都是百度的产品,当用户登录过百度贴吧后,再打开百度地图,系统便自动帮用户登录百度地图,这就属于单点登录。
具体实现方案
普通的登录认证过程

如上图所示,我们在浏览器(Browser)中访问一个业务系统,这个业务系统需要登录,我们填写完用户名和密码后,完成登录认证。这时,我们在这个用户的session中标记登录状态为yes(已登录),同时在浏览器(Browser)中写入Cookie,这个Cookie是这个用户的唯一标识。下次我们再访问这个应用的时候,请求中会带上这个Cookie,服务端会根据这个Cookie找到对应的session,通过session来判断这个用户是否登录。如果不做特殊配置,这个Cookie的名字叫做JSESSIONID
,值在服务端(server)是唯一的。
同域名下的单点登录
cookie
的domin
属性设置为当前域的父域,并且父域的cookie会被子域所共享。path属性默认为web应用的上下文路径。
利用 Cookie 的这个特点,我们只需要将 Cookie 的 domain属性设置为父域的域名(主域名),同时将 Cookie 的 path 属性设置为根路径,将Token
保存到父域中。这样所有的子域应用就都可以访问到这个 Cookie。
具体来看:现在我们需要一个登录系统,叫做sso.machao.com
。我们只要在sso.machao.com
登录,这样tieba.machao.com
和 map.machao.com
也就登录了。通过上面的SSO实现机制,我们能发现,在sso.machao.com
中登录了,其实是在sso.machao.com
的服务端的session中记录了登录状态,同时在浏览器端(Browser)的sso.machao.com
下写入了Cookie。那么我们怎么才能让tieba.machao.com
和 map.machao.com
登录呢?这里有两个问题:
-
Cookie是不能跨域的,我们Cookie的domain属性是
sso.machao.com
,在给tieba.machao.com
和map.machao.com
发送请求是带不上的。 -
sso、tieba和map是不同的应用,它们的session存在自己的应用内,是不共享的。
那么我们如何解决这两个问题呢?
-
针对第一个问题,我们先来了解一下
cookie
的一些性质.cookie
的domin
属性设置为当前域的父域,并且父域的cookie会被子域所共享。path属性默认为web应用的上下文路径。利用 Cookie 的这个特点,我们只需要将 Cookie 的 domain属性设置为父域的域名(主域名),同时将 Cookie 的 path 属性设置为根路径,将Token
保存到父域中。这样所有的子域应用就都可以访问到这个Cookie。不过这要求应用系统的域名需建立在一个共同的主域名之下,如tieba.machao.com
和map.machao.com
,它们都建立在machao.com
这个主域名之下,那么它们就可以通过这种方式来实现单点登录。 -
针对第二个的session问题。当我们在sso系统登录了,这时再访问tieba,Cookie也带到了tieba的服务端(Server),tieba的服务端怎么找到这个Cookie对应的Session呢?这里就要把3个系统的Session共享,如图所示。共享Session的解决方案有很多,例如:Spring-Session(可参考链接:https://www.cnblogs.com/softidea/p/10323281.html)。这样第2个问题也解决了。
流程可参考下图:

不同域名下的单点登录
目前比较常见的还是不同顶域下的多业务系统如何实现sso登录,因为不同顶域之间的Cookie是无法共享的,这时候就无法用到上面提到的同顶域下的单点登录了。那接下来我们就具体来看一下CAS标准的单点登录流程。 引用一下别的大佬画的图(参考链接:https://blog.csdn.net/qq_21251983/article/details/52695206):

上图是CAS的标准单点登录流程,也是企业中最常用到的sso登录标准规范,具体流程如下:
(1)用户访问app1系统,app1系统是需要登录的,但用户现在没有登录。
(2)跳转到CAS server,即SSO登录系统,图中的CAS Server就是SSO系统。SSO系统也没有登录态(浏览器端没有cookie,服务器端没有session),弹出用户登录页。
(3)用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO服务端的session,浏览器中写入SSO域下的Cookie。
(4)SSO系统登录完成后会生成一个ST(Service Ticket),然后跳转到app1系统,同时将ST作为参数传递给app系统。
(5)app1系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。
(6)验证通过后,app1系统将登录状态写入session并设置app1域下的Cookie。
至此,跨域单点登录就完成了。以后我们再访问app1系统时,app1就是登录的。
接下来,我们再看看访问app2系统时的流程。
(1)用户访问app2系统,app2系统没有登录,跳转到SSO。
(2)由于此时SSO已经登录了,不需要重新登录认证。
(3)SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2。
(4)app2拿到ST,后台访问SSO,验证ST是否有效。
(5)验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。
这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。
另外:
用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话。
用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心。
全局会话与局部会话有如下约束关系:
- 局部会话存在,全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话必须销毁
前后端代码实现
前端代码
// axios.js
const CAS = 'https://sso.machao.com/cas/login';
const CALLBACK = window.location.origin + '/rest/sso/callback';
const AUTO_GOTO_CAS_SKIP_URL = ['/']; // 有的首页需要自动sso登录的话,可以将这个注释掉
axios.interceptors.request.use(config => {
config.headers.Accept = 'application/json';
config.withCredentials = true;
config.baseURL = API_URL;
return config;
});
axios.interceptors.response.use(
res => {
const { data } = res;
if (data && data.code === '200') {
return data;
}
if (data && data.code !== '200' && data.message) {
message.error(data.message);
}
return Promise.reject(data);
},
// 前端和后端约定好无登录态返回的状态码401
err => {
if (err?.response?.status === 401 && !AUTO_GOTO_CAS_SKIP_URL.includes(window.location.pathname)) {
// 跳转sso登录页面,service里是业务系统对应的登录鉴权接口,拿到cas登录后的ticket后用callback接口进行权限认证,鉴权通过,会在业务域名下种业务系统局部登录态的cookie
// 认证通过,就重定向到浏览器url输入的原页面
window.location.href = `${CAS}?service=${encodeURIComponent(
CALLBACK + `?redirect=${encodeURIComponent(window.location.href)}`
)}`;
}
Promise.reject(err);
}
);
后端代码
// controller层
@ResponseBody
@RequestMapping(value = "/callback")
public void callback(HttpServletRequest request, HttpServletResponse response) throws IOException {
String userName = getUserName(request);
if (StringUtils.isNotBlank(userName)) {
String userToken = tokenService.getTokenByUserName(userName);
log.info("userName: {}, userToken:{}", userName, userToken);
Cookie cookieToken = new Cookie("userToken", userToken);
cookieToken.setMaxAge(ApplicationConstants.COOKIE_AGE_SECONDS);
cookieToken.setPath("/");
cookieToken.setHttpOnly(true);
response.addCookie(cookieToken);
Cookie cookieName = new Cookie("userName", userName);
cookieName.setMaxAge(ApplicationConstants.COOKIE_AGE_SECONDS);
cookieName.setPath("/");
cookieName.setHttpOnly(true);
response.addCookie(cookieName);
String redirect = CommonUtils.safeGetParameter(request, "redirect");
SecurityUtils.checkResponseSplitting(redirect);
response.sendRedirect(redirect);
}
}
private String getUserName(HttpServletRequest request) {
String ticket = CommonUtils.safeGetParameter(request, "ticket");
if (CommonUtils.isNotBlank(ticket)) {
try {
Assertion assertion = validator.validate(ticket, constructServiceUrl(request));
return assertion.getPrincipal().getName();
} catch (Exception e) {
log.error("failed to validate sso", e);
}
}
log.warn("sso callback, ticket is blank");
return null;
}
private String constructServiceUrl(HttpServletRequest request) throws Exception {
String uri = request.getRequestURI();
String redirect = request.getParameter("redirect");
if (StringUtils.isNotBlank(redirect)) {
uri = uri + "?redirect=" + URLEncoder.encode(redirect, "UTF-8");
}
return "https://" + request.getServerName() + uri;
}
/**
* Spring MVC 配置
*
*/
@Configuration
class SpringMvcConfig {
private static final int TOKEN_FILTER_ORDER = xxxx;
@Bean
WebServerCustomizer applicationWebServerCustomizer() {
return new WebServerCustomizer();
}
@Bean
WebMvcConfigurer webMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addFormatters(@Nonnull FormatterRegistry registry) {
// 用于 string <==> HasIntValue 之间的转换
HasIntValueConverter converter = new HasIntValueConverter();
registry.addConverter(converter);
}
};
}
@Bean
public TokenFilter tokenFilter() {
return new TokenFilter();
}
@Bean
public FilterRegistrationBean<TokenFilter> filterRegistrationBeanTokenFilter(TokenFilter tokenFilter) {
FilterRegistrationBean<TokenFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(tokenFilter);
registrationBean.setOrder(TOKEN_FILTER_ORDER);
return registrationBean;
}
}
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.codec.binary.Base64;
import org.springframework.http.HttpStatus;
import com.google.common.collect.Sets;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class TokenFilter implements Filter {
private static final String KEY = "xxxx";
private static final String ROOT_PATH = "/";
private PatternFilter allowList = new PatternFilter(Sets.newHashSet("/rest/sso/callback"));
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
log.warn("enter doFilter");
//本地环境不校验
if (ApplicationEnv.current() == EnvironmentEnum.DEV) {
log.warn("enter dev");
chain.doFilter(request, response);
return;
}
//白名单path不校验
String path = ((HttpServletRequest) request).getServletPath();
if (ROOT_PATH.equals(path) || this.allowList.contains(path)) {
log.warn("enter white path");
chain.doFilter(request, response);
return;
}
Cookie[] cookies = ((HttpServletRequest) request).getCookies();
log.info("cookies:");
if (cookies == null) {
log.warn("TokenFilter.doFilter: cookies is null");
((HttpServletResponse) response).setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
String userNameByToken = null;
String userName = null;
for (Cookie cookie : cookies) {
log.info("进入cookie循环");
if (Objects.equals("userToken", cookie.getName())) {
userNameByToken = getUserNameByToken(cookie.getValue());
} else if (Objects.equals("userName", cookie.getName())) {
userName = cookie.getValue();
}
}
if (userNameByToken != null && Objects.equals(userNameByToken, userName)) {
log.info("TokenFilter success for {}", userName);
chain.doFilter(request, response);
return;
}
//鉴权失败,给客户端返回401
log.warn("TokenFilter.doFilter: UNAUTHORIZED");
((HttpServletResponse) response).setStatus(HttpStatus.UNAUTHORIZED.value());
}
@Override
public void destroy() {
log.info("destroy");
}
private static String getUserNameByToken(String token) {
int index = token.indexOf("-");
if (index != -1) {
byte[] bytes = Base64.decodeBase64(token.substring(0, index));
Key keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher;
try {
cipher = Cipher.getInstance("AES");
cipher.init(2, keySpec);
String userName = new String(cipher.doFinal(bytes), StandardCharsets.UTF_8);
if (token.substring(index + 1).equals(userName)) {
return userName;
}
} catch (Exception var6) {
log.error("aes decrypt error. token: {}", token, var6);
}
}
return null;
}
public static class PatternFilter {
private final List<Pattern> patternList = new LinkedList<>();
public PatternFilter(Set<String> patternStrList) {
if (Objects.isNull(patternStrList) || patternStrList.isEmpty()) {
return;
}
init(patternStrList);
}
private void init(Set<String> patternStrList) {
for (String regex : patternStrList) {
Pattern pattern = Pattern.compile(regex);
this.patternList.add(pattern);
}
}
public boolean contains(String path) {
for (Pattern p : this.patternList) {
if (p.matcher(path).find()) {
return true;
}
}
return false;
}
}
}
作者介绍