单点登录系统SSO解析及开发小结
在分布式系统架构下是每一个系统都是由一个团队进行维护,每个系统都是单独部署运行一个单独的应用容器如Tomcat,所以,不能将用户的登录信息保存到session中。
多个tomcat的session一般是不能共享的,虽然我们可以利用tomcat自身的session同步功能,但随着机器和业务量增加,效率会越来越低。而且这样做会使业务和Tomcat严重耦合,不利于扩展,所以我们需要一个单独的系统来维护用户的登录信息,这个系统就是SSO即单点登录系统。
单点登录是一个热门话题,是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。
百度百科是这样解释单点登录的。
传统的All-in-one型应用的认证系统是和业务系统集合在一起的,当用户认证通过时,将用户信息存入session中。其他业务只需要从业务中通过对应的key取到用户信息进行相关业务处理即可。它的流程图是这样的。
单点登录系统由于其特殊性,不能使用容器的session,只能由用户自己基于session的性质重新开发一套有状态的会话保持系统。它的流程图如下。
解释
传统的session是将用户信息存入内存,维护一个哈希表。每一次请求携带JSESSIONID到服务端,根据此JSESSIONID查找到对应的用户信息。
由此出发我们想到可以利用Redis等内存数据库进行用户信息的存储,自定义Token生成规则将用户信息写入Redis中。这样将用户信息的存储和业务系统进行拆分,使系统更加健壮,更易于扩展。新加的系统只需要从SSO中获取相关的认证即可进行横向的业务扩展。而且Redis本身的性质也易于进行集群化的部署。
如何开发
最近在做毕设,通过Dubbox对业务进行了拆分,多个系统均需要用到用户登录信息;而且这些业务系统部署在不同的web应用容器中,因此开发一套SSO就成为自然而然的事情。
建立工程,包结构如下
核心框架采用了Spring4+mybatis3+springMVC,这里为了避免文章过于冗长,我就不贴所有的代码了,只贴关键代码。在随后的总结文章我会给出整个工程的github地址及如何部署。
UserController 用户登录注册相关控制器
package com.blog.sso.controller;
...省略import代码
@RequestMapping("user")
@Controller
public class UserController {
//登录成功跳转页面
private static final String INDEX_PAGE = "http://www.blog.com/index.html";
//cookie键名称
private static final String COOKIE_NAME = "LifeSharing_User_Token";
@Autowired
UserService userService;
...省略页面跳转相关代码
...省略注册action
/*登录action*/
@RequestMapping(value = "doLogin", method = RequestMethod.POST)
public String doLogin(@RequestParam("userEmail") String userEmail,
@RequestParam("userPwd") String userPwd,
HttpServletRequest request,
HttpServletResponse response) {
if (StringUtil.isEmpty(userEmail) || StringUtil.isEmpty(userPwd)) {
request.setAttribute("userEmail", userEmail);
request.setAttribute("loginMsg", "请输入正确的用户名及密码");
return "login";
} else {
//组装用户数据验证数据合法性
User user = User.builder().userEmail(userEmail).userPwd(userPwd).build();
//获取登录成功后的Token
String token = null;
try {
token = userService.isUserLogin(user);
} catch (Exception e) {
e.printStackTrace();
}
if (null != token) {
//查询到用户所有数据
User userInfo = this.userService.queryUserByUseremail(userEmail);
//被拉黑无法登陆
if (1 == userInfo.getIsBanned()) {
request.setAttribute("loginMsg", "您已被拉黑,请联系管理员");
return "login";
}
//登录成功,保存token到cookie中
CookieUtils.setCookie(request, response, COOKIE_NAME, token);
//request.getSession().setAttribute("user", userInfo);
return "redirect:" + INDEX_PAGE;
} else {
//数据回显
request.setAttribute("userEmail", userEmail);
request.setAttribute("loginMsg", "用户名或密码错误");
return "login";
}
}
}
}
核心在于登录业务的处理。
首先对前台登录表单中传来的用户名及密码进行验证,如果输入合法,则生成该用户对应的Token,生成规则由开发者自行制定。这里我将用户的邮箱和当前的时间合并进行MD5哈希。
String token = DigestUtils.md5Hex(user.getUserEmail() + System.currentTimeMillis());
将token及Json序列化后的用户信息json串写入Redis中,此处的RedisService是自行封装的Redis操作类,与Spring进行集成。
redisService.set("TOKEN_" + token, MAPPER.writeValueAsString(userInfo), REDIS_TIME);
在controller中将token写入到cookie中,CookieUtils是自行封装的设置cookie工具类
CookieUtils.setCookie(request, response, COOKIE_NAME, token); ================================================================ /** * 设置Cookie的值,并使其在指定时间内生效 * * @param cookieMaxage cookie生效的最大秒数 */ private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) { try { if (cookieValue == null) { cookieValue = ""; } else if (isEncode) { cookieValue = URLEncoder.encode(cookieValue, "utf-8"); } Cookie cookie = new Cookie(cookieName, cookieValue); if (cookieMaxage > 0) cookie.setMaxAge(cookieMaxage); if (null != request)// 设置域名的cookie cookie.setDomain(getDomainName(request)); cookie.setPath("/"); response.addCookie(cookie); } catch (Exception e) { logger.error("Cookie Encode Error.", e); } }
下图为cookie中的token信息
- 跳转到业务系统。在这里需要和后端的服务接口进行对接,从Redis根据cookie中的token值取出对应的用户数据并写入当前的session中
这里的ssoUserQueryService是dubbox中注册的单点接口,根据对应的Token获取用户的序列化json数据并反序列化为User对象。
实现类如下:通过redisService工具类将token注入并调用Jedis的get方法拿到对应的Json字符串;然后通过Jackson的ObjectMapper中的readValue方法将json串反序列化为对象并返回
@Service
public class SSOUserQueryServiceImpl implements ISSOUserQueryService {
private static final Logger LOGGER = Logger.getLogger(SSOUserQueryServiceImpl.class);
//Jackson映射类
private static final ObjectMapper MAPPER = new ObjectMapper();
//会话超时时间
private static final Integer REDIS_TIME = 60 * 30;
@Autowired
private RedisService redisService;
//通过用户token查询用户信息
@Override
public User queryUserByToken(String token) {
//1.通过Token的key取得序列化用户信息
String userJsonData = redisService.get(token);
LOGGER.info(token);
if (StringUtils.isEmpty(userJsonData)) {
//登录超时
return null;
}
//2.重设Redis生存时间
this.redisService.expire(token, REDIS_TIME);
//3.反序列化Json为对象
try {
User userFromJson = MAPPER.readValue(userJsonData, User.class);
return userFromJson;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
- 在业务系统中取出对应的用户数据
@CookieValue这个注解用于从cookie中取出对应key的value,也就是之前存入的cookie值(Token)
通过token调用ssoUserQueryService的queryUserByToken(USER_TOKEN)方法查出Redis中的用户数据设置到本容器的session中
如果SSO需要集群部署,访问到的每一台tomcat都能携带相同的用户数据,从而使得用户数据同步。当然需要考虑统一注销的功能的实现,这里可以采用MQ进行发布订阅形式的调用,超出了本文的范围先不谈。
然后将页面的数据放到Map中并跳转到前端界面中进行展现
代码如下
@RequestMapping(value = "index", method = RequestMethod.GET)
public String toLogin(
@CookieValue(value = "LifeSharing_User_Token") String LS_User_Token ,
HttpServletRequest request,
HttpSession session) throws Exception {
/*
* 获取cookie中的值
* 先判断Redis中是否有用户信息
* @CookieValue 通过设置key取得对应的cookie值
*/
USER_TOKEN = "TOKEN_" + LS_User_Token;
//未登录访问主页或者退出后访问均重定向到登录页
if (StringUtils.isEmpty(LS_User_Token) || (null == redisService.get(USER_TOKEN))) {
return "redirect:" + LOGIN_PAGE;
} else {
//1.通过Token的key取得序列化用户信息
String userInfoJsonSerializable = redisService.get(USER_TOKEN);
//2.反序列化Json为对象
User userFromJson = ssoUserQueryService.queryUserByToken(USER_TOKEN);
//将该用户设置到Session
session.setAttribute("user", userFromJson);
//从序列化后的对象中取得用户的ID
Integer userID = userFromJson.getU_id();
//页面加入数据再返回
Map<String, Object> indexDataMap = blogService.getIndexDataMap(userID);
List<GuanzhuQueryVO> guanzhuQueryVOs = (List<GuanzhuQueryVO>)indexDataMap.get("guanzhuList");
//TODO 删掉此处日志并将数据传到前台
LOGGER.info(guanzhuQueryVOs.toString());
@SuppressWarnings("unchecked")
//博客列表数据
List<BlogQueryVO> blogs = (List<BlogQueryVO>) indexDataMap.get("bloglist");
request.setAttribute("bloglist", blogs);
return "index";
}
页面效果如图
Redis中的序列化用户数据如图(使用Redis Desktop Manager工具进行查询)
PS:注销功能
@RequestMapping(value = "logout", method = RequestMethod.GET)
public String adminLogout(HttpServletRequest request) {
if (null != request.getSession()) {
//移除会话信息
request.getSession().removeAttribute("user");
//移除Redis中用户信息
redisService.del(USER_TOKEN);
}
return "redirect:" + LOGIN_PAGE;
}
首先移除本容器中的会话信息,同时删除Redis中的本用户的序列化信息。这样做能够有效避免直接通过url绕过登录访问系统,这么做则直接跳转到SSO登录页面。
小结
单点登录系统是分布式应用中不可或缺的一环,对于解耦应用及增强应用的健壮性有着重要的作用。希望本文能够对想要开发SSO系统的读者有所启迪。