单点登录的原理和实现
[单点登录](Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
很早期的公司,一家公司可能只有一个Server,慢慢的Server开始变多了。每个Server都要进行注册登录,退出的时候又要一个个退出。用户体验很不好!
这就是今天要说的单点登录
登录的原理
前因
由于 http 是无状态的,所以每次请求都相当于是新的,那么系统是怎么记住的呢,这个时候 session 和 cookie这两个东西出现了。第一次访问 server ,tomcat会生成一个 cookie,记录 sessionId 并且返回到浏览器,比如你访问你的是 a.com,那么第一次访问 a.com之后,浏览器就会多了一个 cookie,这个 cookie 的作用域就是 a.com,下次访问 a.com 的时候就会自动带上这个 cookie,然后服务器判断是不是第一次访问。cookie 和 session 是相辅相成的。
后果
判断登录是否成功有两种方法:
第一种是:判断 cookie 中带的 sessionId 我服务器存不存在,存在成功,反之失败。
第二种是:第一次登录成功之后给当前 session 设个值,然后每次访问进来都判断进来的 sessionId ,用这个 sessionId 能拿到值则登录成功,反之失败。
流程
image.png单点登录原理
image.png- 浏览器访问应用1中的受限资源
- 判断没登录,则跳转到认证中心登录页面
- 登录成功,跳转回应用1,附带登录成功的 token
- 应用通过 http 请求验证 token 是否正确有效
- token 有效
- 返回受限资源,允许访问
- 访问应用2中的受限资源
- 应用通过 http 请求验证是否已登录
- 返回已登录
- 返回受限资源,允许访问
细心的人可能会问,在第8步的时候,应用2通过什么判断是否已登录,如果不同域名的话,传不过 cookie,这个问题的我只能想到两种解决方案:
- 应用1和应用2保持同一个二级域名,这样就可以实现 cookie 共享了。例如:app1.sso.com,app2.sso.com
- 如果非要跨域的话,我觉得站内跳转是可行的,假如有 app1.com 和 app2.com, 在 app1.com 中加个跳转的按钮,点击这个按钮执行的还是 app1.com 的接口,在接口中跳转到 app2.com,跳转的时候根据当前 cookie拿到用户信息,这样在 app2.com 认证成功之后也可设置 app2.com 的 cookie。
- 还有一种方式就是通过父域的方式,cookie 存放在 app.com路径 中,app1和 app2通过 iframe 嵌套在 app 中,这样怎么跳都可以拿到 app 的 cookie,这是我在别的地方看到的,好像是可行。
UML 图
这次我通过了共享 cookie实现的单点登录:
image.png有个这个 uml 图就相对比较清晰了,自己完善个代码基本不难了
因为涉及到会话管理,有些 session 什么的需要处理,所以我在这边接管了框架的 session 的存储,也就是实现了HttpSessionListener监听器,监听 session 的创建和删除,加了一个 map ,根据 sessionId 来存储和管理。
在 web.xml 中增加监听的配置
image.png
在判断是否登录的时候,我可以直接调用getSession根据 sessionId来拿到HttpSession 对象,就像这样:
Object auth =LocalSessionManager.getSession(session.getId()).getAttribute("token_info");
来判断当前应用是否有没有setAttribute值,有就是登录过,空就是没登录。这个作用就是防止每次判断是否登录都要去认证中心,那样认证中心压力太大了。
关键代码
我本地设置的 hosts:
127.0.0.1 app1.sso.com
127.0.0.1 app2.sso.com
127.0.0.1 server.sso.com
app1端口是8083
app2端口是8084
server端口是8082
也在此奉上关键代码:
- sso-client的拦截器
/**
* springmvc 拦截器
* @param request
* @param response
* @param o
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
String url = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getRequestURI();
HttpSession session = request.getSession();
Object auth = LocalSessionManager.getSession(session.getId()).getAttribute("token_info");
String token = request.getParameter("token");
//loginOut 不拦截
if (request.getRequestURI().contains("loginOut") || request.getRequestURI().contains("toLoginOut")) {
return true;
}
String userName = UrlUtils.getUserNameByCookies(request.getCookies());
//说明当前用户为登录状态
if (auth != null) {
return true;
}
//说明是sso服务器调用的,稍后可能会改成别的判断
if (token != null) {
Map<String, Object> param = new HashMap<>();
param.put("token", token);
param.put("appUrl", request.getServerName() + ":" + request.getServerPort());
String result = HttpUtils.httpPostRequest(serverUrl + "user/checkToken", param);
if (resultHandle(result, request, response)){
return true;
}
}
//说明当前用户 有 cookie,可能认证中心已经登录了
if (auth == null && userName != null) {
//判断sso服务器是否已经登录了
Map<String, Object> params = new HashMap<>();
params.put("appSessionId", session.getId());
params.put("appUrl", request.getServerName() + ":" + request.getServerPort());
params.put("username", userName);
String result1 = HttpUtils.httpPostRequest(serverUrl + "user/checkLogin", params);
if (resultHandle(result1, request, response)){
return true;
}
}
response.sendRedirect(serverUrl + "?back=" + UrlUtils.encodeUrlWithSessionId(url, session.getId()));
return false;
}
/**
* 处理 server 返回的内容
* @param result
* @param request
* @param response
* @return
*/
private boolean resultHandle(String result, HttpServletRequest request, HttpServletResponse response) {
JSONObject jsonResult = JSONObject.parseObject(result);
if (jsonResult.getBoolean("success")) {
UserInfo info = JSONObject.parseObject(jsonResult.getString("data"), UserInfo.class);
HttpSession sessionLocal = LocalSessionManager.getSession(info.getLocalSessionId());
if (sessionLocal != null) {
Cookie cookie = new Cookie("_token_security", UrlUtils.encodeUrl(Base64Utils.encodeBase64(info.getUserName())));
cookie.setPath("/");
cookie.setDomain(".sso.com");
response.addCookie(cookie);
System.out.println("url:" + request.getServerName());
sessionLocal.setAttribute("token_info", info);
return true;
}
return true;
}
return false;
}
- sso-server 的登录 controller
@RequestMapping(value = "/login", method = RequestMethod.GET)
@ResponseBody
public void login(String username, String password, String target,
HttpServletRequest request, HttpServletResponse response) throws IOException {
if (username == null) {
username = request.getParameter("name");
}
if (password == null) {
password = request.getParameter("pwd");
}
if (target == null) {
target = request.getParameter("target");
}
User user = userService.login(username, password);
if (user != null) {
String appSessionId = target.split("&")[1];
UserInfo info = new UserInfo(request.getSession().getId(), appSessionId, username, null);
String token = TokenUtils.takeTokenWithUserInfo(info);
response.sendRedirect(URLUtils.decodeUrl(target.split("&")[0]) + "?token=" + token);
return;
}
response.sendRedirect("/login.jsp?target=" + target);
}
@RequestMapping(value = "/checkToken", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> checkToken(String token, String appUrl) {
Map<String, Object> map = new HashMap();
map.put("success", false);
UserInfo user = TokenUtils.getUserInfo(token);
if (user != null) {
user.setAppUrl(appUrl);
//存入用户和应用的关联
UserAppManager.add(user.getUserName(), appUrl);
//重新初始化 token,是刚才的 token 失效,目的是 token 验证只能用一次
TokenUtils.updataTokenWithUserInfo(token, user);
map.put("success", true);
map.put("data", user);
}
return map;
}
@RequestMapping(value = "/checkLogin", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> checkLogin(String appUrl, String username,String appSessionId, HttpServletRequest request, HttpServletResponse response) {
username = Base64Utils.decodeBase64(URLUtils.decodeUrl(username));
Map<String, Object> map = new HashMap();
map.put("success", false);
UserInfo user = TokenUtils.getUserInfoByUserName(username);
//当前账号已登录
if (user != null) {
//当前应用没登录
if (TokenUtils.getUserInfoByNameUrl(username,appUrl)==null){
UserInfo info=new UserInfo(user.getGloalSessionId(),appSessionId,username,appUrl);
//存入用户和应用的关联
UserAppManager.add(username, appUrl);
TokenUtils.takeTokenWithUserInfo(info);
map.put("data", info);
map.put("success", true);
return map;
}else {
map.put("data", TokenUtils.getUserInfoByNameUrl(username,appUrl));
map.put("success", true);
return map;
}
}
return map;
}
@RequestMapping(value = "/loginOut", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> loginOut(String username) {
username = Base64Utils.decodeBase64(URLUtils.decodeUrl(username));
Map<String, Object> map = new HashMap();
map.put("success", false);
try {
Set<String> urls = UserAppManager.getByName(username);
if (urls != null) {
for (String url : urls) {
//远程删除 session
UserInfo info = TokenUtils.getUserInfoByNameUrl(username, url);
Map<String, Object> params = new HashMap();
params.put("sessionId", info.getLocalSessionId());
String targetUrl = "http://" + url + "/user/toLoginOut";
HttpUtils.httpPostRequest(targetUrl, params);
}
}
TokenUtils.deleteByName(username);
UserAppManager.deleteByName(username);
map.put("success", true);
return map;
} catch (Exception e) {
logger.error("loginOut error",e);
}
return map;
}
github 地址:
https://github.com/thecattle/sso-client
https://github.com/thecattle/sso-server
ps:自己瞎写的,要是有大佬看到,还请多多指出错误