SSO 概念
单点登录(Single Sign On),简称为 SSO;
SSO核心意义就一句话:一处登录,处处登录;一处注销,处处注销。
即用户只需要记住一组用户名和密码就可以登录所有有权限的系统。
使用场景
单点登录在大型网站里使用得非常之多,阿里旗下淘宝、天猫、支付宝、阿里巴巴,阿里妈妈,阿里妹妹等网站,以及这些网站背后成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协作,每一个系统都要登录一次,用户与开发人员岂不是要疯掉。
所以,单点登录要解决的就是,用户只需要登录一次就可以访问所有相互信任的应用系统。
单点登录过程
如下图所示:
各个工作节点
用户(手机、浏览器、其他…)
系统(APP-1、APP-2、APP-3…)
认证中心(sso)
用户信息(数据库、redis、缓存、cas…)
单点登录过程
- 当用户第一次访问应用APP-1的时候,因为还没有登录,会被引导到认证系统(SSO)的登录页面进行登录;
- 用户在登录页面提供登录信息,一般是用户名、密码,认证系统进行身份效验,如果效验通过,登录成功,同时应该返回给用户一个认证的凭据-ticket;
- 用户再访问别的应用APP-2、APP-3的时候,利用cookie、session或者其他方式,将会带上这个ticket,作为自己认证的凭据,应用系统接受到请求之后会把ticket送到认证系统进行效验,检查ticket的合法性。如果通过效验,用户就可以在不用再次登录的情况下访问应用APP-2、APP-3了。
- 使用完系统后,需要退出登录,假设用户在APP-1注销登录,那么APP-1不能再访问,APP-2、APP-3、…也跟着不能再访问,通俗理解就是APP-1、APP-2、APP-3…任意系统全都要注销登录。
SSO 实现方式
- 基于Cookie的单点登录
最简单的单点登录实现方式,是使用cookie作为媒介,存放用户凭证。 用户登录父应用之后,应用返回一个加密的cookie,当用户访问子应用的时候,携带上这个cookie,授权应用解密cookie并进行校验,校验通过则登录当前用户- Cookie不安全
- 不能跨域实现免登
- 分布式session单点登录
用户第一次登录时,将会话信息(用户Id和用户信息),比如以用户Id为Key,写入分布式Session;
用户再次登录时,获取分布式Session,是否有会话信息,如果没有则跳转到登录页;
一般采用Cache中间件实现,比如Redis,redis有持久化功能,方便分布式Session宕机后,可以从持久化存储中加载会话信息。 - spring-security + oauth2
- shiro + cas
-
session方式举例,代码实现
注:我这里用session方式举例,cookie等其他方式方式可自行研究
选用任意一种浏览器,用于用户做登录操作、请求APP资源
- 客户端-sso-client:搭建一个客户端-sso-client,这个client只处理登录逻辑,再搭建多个APP,这些APP分别引入这个client
注:以下我在说明APP-的时候,统一用sso-client-,
sso-client-1 | sso-client-2 | sso-client-3 |
---|---|---|
端口:8081 | 端口:8082 | 端口:8083 |
页面:index.html | 页面:home.html | 页面:work.html |
各种资源接口 | 各种资源接口 | 各种资源接口 |
还要搭建一个认证中心-sso-server | sso-server | | —- | | 端口:8080 | | 页面:login.html | | 各种接口 |
这个sso-server主要做以下这几个事情
- 登录
- 保存登录信息
- 校验ticket
- 注销登录
- 存储用户信息,咱们就简单点,直接在sso-server用静态的map、list等结构存储,当然也可以换成其他方式,比如:数据库、Redis…
客户端-sso-client-1、客户端-sso-client-2、客户端-sso-client-3
- 客户端-sso-client,这里用spring拦截器处理,且假设拦截所的有访问,如下代码
@Configuration
public class LoginConfig implements WebMvcConfigurer{
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册TestInterceptor拦截器
InterceptorRegistration registration = registry.addInterceptor(new SsoHander());
//所有请求都被拦截
registration.addPathPatterns("/**");
}
}
- 既然sso-client要处理登录逻辑,具体的登录、校验、保存用户信息等操作是在认证中心(sso-server),所以咱们得让sso-client知道sso-server地址,所以需要在sso-client写一个sso-server地址配置,为了方便,我就在这里写死吧。
- SSO_SERVER: sso-server地址
- SSO_SERVER_VERIFY: sso-server校验ticket地址
- SSO_SERVER_CHECK_LOGIN: sso-server检验是否登录的地址
/**
* SsoHander
* 处理登录逻辑
*/
public class SsoHander implements HandlerInterceptor {
/** sso-server地址*/
private static final String SSO_SERVER = "http://localhost:8080/sso";
private static final String SSO_SERVER_VERIFY = "/verify";
private static final String SSO_CHECK_LOGIN = "/checkLogin";
/** session 中的登录标志*/
private static final String IS_LOGIN = "isLogin";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = req.getSession();
//是否已经存在回话
Boolean isLogin = (Boolean) session.getAttribute(IS_LOGIN);
if (isLogin != null && isLogin) {
return true;
}
// 校验ticket
String ticket = req.getParameter("ticket");
if (StringUtils.isEmpty(ticket)) {
ssoSendRedirect(req, resp);
return true;
} else {
// 到sso-verver校验ticket
if (verify(req, session, ticket)) {
return true;
} else {
ssoSendRedirect(req, resp);
}
}
return false;
}
private void ssoSendRedirect (HttpServletRequest req, HttpServletResponse resp) throws IOException {
String service = ServerUtil.getCurAddr(req) + req.getServletPath();
resp.sendRedirect(SSO_SERVER + SSO_SERVER_CHECK_LOGIN + "?service=" + service);
}
private boolean verify (HttpServletRequest req, HttpSession session, String token) {
//去sso校验ticket
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("token", token);
paramMap.put("service", ServerUtil.getCurAddr(req) + "/logout");
paramMap.put("jsessionid", session.getId());
String str = HttpUtil.httpPost(SSO_SERVER + SSO_SERVER_VERIFY, paramMap);
if (str != null && "true".equals(str)) {
session.setAttribute(IS_LOGIN, true);
return true;
}
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
}
}
- 我现在通过浏览器访问http://localhost:8081/sso-client-1/index.html,嘿嘿,要过拦截器,也就进入了方法preHandle(***) 方法
- 首先就得判断sso-client-1是否已经有了session 回话,哎呀,第一次进来,过不了呀
- 那就得再判断一下ticket,这个时候,哪有什么ticket,还是直接通过ssoSendRedirect(*)重定向到sso-server的单点登录页面吧
- 不过为了让sso-server知道现在是sso-client-1重定向到sso-server的单点登录页面,且登录后再回到sso-client-1,得主动给个标记:service=http://localhost:8081/sso-client-1/index.html
- 咱们再访问sso-client-1的其他资源,比如//http://localhost:8081/sso-client-1/world.html,再次通过拦截器,这是时候sso-client-1已经有session直接放行该请求
- 我再次访问sso-client-2,这是一个新系统,我们访问资源:http://localhost:8081/sso-client-2/home.html,拦截器拦截,这时没有session和ticket,那就直接去sso-verver校验,由于sso-server的session 已经有了,所以校验通过,也就再次重定向,http://localhost:8081/sso-client-1/home.html,拦截器拦截,嘿嘿,这个时候已经有了ticket,老规矩,verify(***)检验ticket,并且校验通过会在sso-client-1设置session,放行该请求
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
<meta http-equiv="Access-Control-Allow-Origin" content="*">
<script type="text/javascript" src="js/lib/jquery-3.4.1.min.js"></script>
<script type="text/javascript" src="js/login.js"></script>
</head>
<body>
<div>
<form id="fmId" action="/sso/login" method="post">
<input id="serviceId" name="service" type="text" th:value="${service}">
<input id="usernameShowId" name="username" type="text">
<input id="passwordShowId" name="password" type="password">
<input type="submit" value="登录">
</form>
</div>
</body>
</html>
- sso-server 检查登录,用户向客户端sso-client-发请求时,sso-client-没有session也没有ticket,那就通过这个方法验证
- sso-server 没有全局session回话,跳转到登录页面:http://localhost:8080/sso/login?service=http://localhost:8081/sso-client-1/index.html
,这个时候要带上sso-client-*传递过来了service,不能丢 - 有全局session回话,重定向到sso-client-*传递过来了service
- sso-server 没有全局session回话,跳转到登录页面:http://localhost:8080/sso/login?service=http://localhost:8081/sso-client-1/index.html
/**
*
* UserLoginController
* @description 检查登录,用户向客户端发请求时,没有session也没有ticket,那就通过这个方法验证
* @param service 用户向客户端发送的请求
* @param session session
* @return 重定向的地址,1、登录页面;2、客户端的请求地址
* @throws UnsupportedEncodingException
* @author fengzhiqiang
* @date 2020年8月23日 下午11:12:32
* @version v1.0.0
*/
@RequestMapping("/checkLogin")
public String checkLogin(
@RequestParam(name = "service", required = false) String service,
HttpSession session) throws UnsupportedEncodingException {
String token = (String) session.getAttribute("token");
if (StringUtils.isEmpty(token)) {
// 没有全局回话,跳转到登录页面
return "redirect:index?service=" + service;
} else {
// 存在全局回话,返回调用的地方
return "redirect:" + service + "?ticket=" + token;
}
}
- 跳转sso-server登录页面,sso-server需要知道是什么叫地方跳转到登录页面,也就是登录端的地址,也就是下面代码中的service参数,比如:http://localhost:8080/sso/index?service=http://localhost:8081/sso-client-1/index.html
- 检验用户名(admin)、密码(123456)
- LoginConstant.DB_TOKEN 是存储token(ticket)
- 成功跳转到service,也就是http://localhost:8081/sso-client-1/index.html
/**
*
* LoginController
* @description 重定向到login.html
* @param service 客户端
* @param model
* @return static/login.html
* @author fengzhiqiang
* @date 2020年3月7日 上午12:49:27
* @version 0.0.1
* @throws UnsupportedEncodingException
*/
@GetMapping("/index")
public String login(@RequestParam(name = "service", required = false) String service, Model model) {
model.addAttribute("service", service);
return "login";
}
/**
* 登录
* LoginController
* @description 登录
* @param username 用户名
* @param password 密码
* @param 地址
* @param req Request
* @return 登录成功
* @author fengzhiqiang
* @date 2020年3月7日 上午12:50:31
* @version 0.0.1
* @throws IOException
*/
@RequestMapping(value = "/login")
public void login(
@RequestParam(name = "username", required = false) String username,
@RequestParam(name = "password", required = false) String password,
@RequestParam(name = "service", required = false) String service,
HttpServletRequest req,
HttpServletResponse resp,
HttpSession session) throws IOException {
if ("admin".equals(username) && "123456".equals(password)) {
//校验成功,创建token信息
String token = UUIDHelper.getUUID32();
session.setAttribute("token", token);
// 模拟存储token
LoginConstant.DB_TOKEN.add(token);
//登录成功,令牌返回到客户端
WebUtils.issueRedirect(req, resp, SsoUtil.getRedirectURL(service, token));
return;
}
//回到登录页面
WebUtils.issueRedirect(req, resp, "login?service=" + service);
}
- sso-server校验ticket,也就是用户向客户端发送请求时,客户端发现没有session,但是这个请求带了ticket,那就需要在sso-server验证该tickt
- 也就是查看LoginConstant.DB_TOKEN是否存在该ticket或者token
- 这里要多做一个处理,得把客户端信息保存起来也就是ClientInfo,包含客户端的url信息,jsessionid信息,这是因为一会儿咱们退出登录时候,需要知道具体的sso-client—*和session
/**
*
* UserLoginController
* @description 校验token是否由统一认证中心产生的
* @param token ticket或token
* @return
* @author fengzhiqiang
* @date 2020年4月16日 上午1:15:36
* @version TODO
*/
@RequestMapping("/verify")
@ResponseBody
public String verifyToken(
@RequestParam(name = "token", required = false) String token,
@RequestParam(name = "service", required = false) String service,
@RequestParam(name = "jsessionid", required = false) String jsessionid){
if (LoginConstant.DB_TOKEN.contains(token)){
//模拟通过tocket获取客户端信息
List<ClientInfo> cs = LoginConstant.DB_CLIENT.get(token);
if (cs == null) {
cs = new ArrayList<>();
LoginConstant.DB_CLIENT.put(token, cs);
}
ClientInfo ci = new ClientInfo();
ci.setClientUrl(service);
ci.setJsessionid(jsessionid);
cs.add(ci);
return "true";
}
return "false";
}
- sso-server退出登录,下面logout方法只是注销了sso-server里面的session,客户端client的session也需要注销,由于可能存在很多的客户端client,那需要一一发送请求去注销,那就sso-serer中通过监听器监听session的注销,需要注意的是,我们在sso-server存储各个客户端信息的时候,是保存了客户端url和jsessionid,通过url和jsessionid,才知道注销的是哪个客户端以及该客户端的哪个session
- 咱们要注销sso-service的session
- 监听sso-service的session注销
- LoginConstant.DB_TOKEN 删除ticket 或者token,
- 然后向各个sso-client—* 发送请求session注销请求,至于发送请求的方法sendHttpRequest()自行实现吧
- 退出了还是要记住是哪个客户端退出的,service=http://localhost:8081/index.html
@RequestMapping("/logout")
public String logout (@RequestParam(name = "service", required = false) String service,
HttpSession session) {
//注销session
session.invalidate();
//登录页面
return "redirect:index?service=" + service;
}
@WebListener
public class SessionListener implements HttpSessionListener{
@Override
public void sessionCreated(HttpSessionEvent se) {
}
/**
* 监听session销毁,然后各个客户端session都销毁
* @see javax.servlet.http.HttpSessionListener#sessionDestroyed(javax.servlet.http.HttpSessionEvent)
*/
@Override
public void sessionDestroyed(HttpSessionEvent se) {
HttpSession session = se.getSession();
String token = (String) session.getAttribute("token");
LoginConstant.DB_TOKEN.remove(token);
List<ClientInfo> cs = LoginConstant.DB_CLIENT.remove(token);
try {
if (CollectionUtils.isNotEmpty(cs)) {
for (ClientInfo clientInfo : cs) {
//发送注销登录请求
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("jsessionid", clientInfo.getJsessionid());
HttpUtil.sendHttpRequest(clientInfo.getClientUrl(), (String) paramMap.get("jsessionid"));
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}