【SSO】单点登录系统
一、单点登录系统介绍
对于一个开发项目来说,每个项目都必不可少要有登录的这个功能。但是随着项目的变大,变大,再变大。系统可能会被拆分成多个小系统,咱们就拿支付宝和淘宝来说,咱们在淘宝上购物,然后就可以直接连接到自己的支付宝,这个过程不需要我们再次登录系统,自动就完成了跳转。这个操作就是小编这次向大家介绍的——单点登录。
1.1 什么是单点登录?
SSO英文全称Single Sign On,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。它包括可以将这次主要的登录映射到其他应用中用于同一个用户的登录的机制。它是目前比较流行的企业业务整合的解决方案之一。
可以看出,咱们使用了单点登录系统,在分布式架构APP中,咱们的系统和系统之间的跳转就可以达到无缝链接了。用户体验度非常好,用户根本没有觉察出是多个系统。这个就达到了我们的目的,即提高了性能,又增加了我们系统之间跳转的灵活度。
1.2 为什么使用单点登录
下面小编从一个技术小白做订餐系统,一步一步演变框架。
首先呢,小编做了一个java Web的项目,这个项目用户进入订餐界面,选择要定的饭后,提交的时候,会判断是否登录。如果没有登录,就会跳转到用户登录界面,然后进行登录的判断;如果已经登录了,就可以直接提交信息了。
这个小系统非常的简单,操作也很流畅。但是它有一个适应范围:非高并发使用流畅。
当我们的这个系统有了很高的并发,就像美团外卖一样,每天的用户很多。我们能做的就是把我们的APP多发布到几个tomcat上,然后通过Nginx反向代理来均分权重。
随之而来的就是Session共享问题了: 用户1登录后,nginx给他分配到tomcat1上,session被存储在tomcat1上。当下次登录可能分配到tomcat2上,这样还需要重新登录。
解决Session共享的问题:
1. tomcat有一个session同步方案,就是一个传播机制,打个比方有A B C 3台tomcat,这3台tomcat的user信息都在session中并且保持一致,如果其中一台的user信息变化了,那么就会传播至另外两台,则实现同步,这样做没问题,但是仅仅只是在做tomcat集群的时候tomcat很少的时候会用,一旦集群增大,有100台,那么就互相传播吧,传播是需要性能损耗的,那么整个网站的性能就会被拉低,形成网络风暴。推荐节点数量不要超过5个。
2、分布式架构。拆分成多个子系统。 独立建立一个单点登录系统,登录后,把用户信息存储到redis,把key值存储到cookie中,当其他系统需要用户信息的时候,就可以通过读取redis中的信息,如果redis中存在,就直接使用。如果不存在就跳转到单点登录系统进行登录。单点登录系统是使用redis模拟Session,实现Session的统一管理。
二、单点登录系统的实现
2.1 环境准备
- eclipse
- redis
2.2 单点登录流程图
这个是简单的单点登录流程图,就拿淘宝来说,当我们进入淘宝首页的时候是没有登录的,点击登录的时候,会跳转到用户登录界面。此时的用户登录界面就是咱们SSO系统的一部分,根据登录的要求,会接收用户名和密码,然后根据用户名查询密码是否正确。
- 如果不正确就跳转到登录页,提示不正确;
- 如果正确就要进行以下步骤:
- 生成一个uuid,作为token;
- 把用户信息序列化存储到redis,存储的key为token,存储成功后,返回token;
- 把token存储到cookie;
- 判断是否有回调url,如果有,跳转到指定url;如果没有,跳转到系统首页;
2.3 登录逻辑实现
【DAO层】
单表查询,可以直接使用Mybatis逆向工程产生的代码。
【Service层 】
-
参数:
-
用户名:String username
-
密码:String password
-
返回值:E3Result,包装token
-
-
业务逻辑 :
-
判断用户名密码是否正确。
-
登录成功后生成token。Token相当于原来的jsessionid,字符串,可以使用uuid。
-
把用户信息保存到redis。Key就是token,value就是TbUser对象转换成json。
-
使用String类型保存Session信息。可以使用“前缀:token”为key
-
设置key的过期时间。模拟Session的过期时间。一般半个小时。
-
返回e3Result包装token。
-
public e3Result login(String username, String password) { // 1、判断用户名密码是否正确。 TbUserExample example = new TbUserExample(); Criteria criteria = example.createCriteria(); criteria.andUsernameEqualTo(username); //查询用户信息 List<TbUser> list = userMapper.selectByExample(example); if (list == null || list.size() == 0) { return e3Result.build(400, "用户名或密码错误"); } TbUser user = list.get(0); //校验密码 if (!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes()))) { return e3Result.build(400, "用户名或密码错误"); } // 2、登录成功后生成token。Token相当于原来的jsessionid,字符串,可以使用uuid。 String token = UUID.randomUUID().toString(); // 3、把用户信息保存到redis。Key就是token,value就是TbUser对象转换成json。 // 4、使用String类型保存Session信息。可以使用“前缀:token”为key user.setPassword(null); jedisClient.set(USER_INFO + ":" + token, JsonUtils.objectToJson(user)); // 5、设置key的过期时间。模拟Session的过期时间。一般半个小时。 jedisClient.expire(USER_INFO + ":" + token, SESSION_EXPIRE); // 6、返回e3Result包装token。 return e3Result.ok(token); }
- 发布服务:
<!-- 使用dubbo发布服务 --> <!-- 提供方应用信息,用于计算依赖关系 --> <dubbo:application name="e3-sso"/> <dubbo:registry protocol="zookeeper" address="192.168.25.128:2181"/> <!-- 用dubbo协议在20880端口暴露服务 --> <dubbo:protocol name="dubbo" port="20883"/> <!-- 声明需要暴露的服务接口 --> <dubbo:service interface="cn.e3mall.sso.service.LoginService" ref="loginServiceImpl" timeout="300000"/>
【Controller层】
功能分析:
-
请求的url:/user/login
-
请求的方法:POST
-
参数:username、password,表单提交的数据。可以使用方法的形参接收。
-
返回值:json数据,使用e3Result包含一个token。
-
业务逻辑:
-
接收两个参数。
-
调用Service进行登录。
-
从返回结果中取token,写入cookie。Cookie要跨域。
-
引用服务:
<!-- 引用dubbo服务 --> <dubbo:application name="e3-sso-web"/> <dubbo:registry protocol="zookeeper" address="192.168.25.128:2181"/> <dubbo:reference interface="cn.e3mall.sso.service.TokenService" id="tokenServiceImpl"/>
LoginController:
@Controller public class LoginController { @Autowired private LoginService loginService; @Value("${COOKIE_TOKEN_KEY}") private String COOKIE_TOKEN_KEY; @RequestMapping(value="/user/login", method=RequestMethod.POST) @ResponseBody public E3Result login(String username, String password, HttpServletRequest request, HttpServletResponse response) { // 1、接收两个参数。 // 2、调用Service进行登录。 E3Result result = loginService.login(username, password); // 3、从返回结果中取token,写入cookie。Cookie要跨域。 String token = result.getData().toString(); CookieUtils.setCookie(request, response, COOKIE_TOKEN_KEY, token); // 4、响应数据。Json数据。e3Result,其中包含Token。 return result; } }
2.4 通过token查询用户信息
【功能分析】
-
请求的url:/user/token/{token}
-
参数:String token需要从url中取。
-
返回值:json数据。使用e3Result包装Tbuser对象。
-
业务逻辑:
-
从url中取参数。
-
根据token查询redis。
-
如果查询不到数据。返回用户已经过期。
-
如果查询到数据,说明用户已经登录。
-
需要重置key的过期时间。
-
把json数据转换成TbUser对象,然后使用e3Result包装并返回。
-
【DAO层】
使用JedisClient对象。
【Service层】
- resource.properties:
#SESSION在redis的过期时间
SESSION_EXPIRE=1800
- 实现类:
@Service public class TokenServiceImpl implements TokenService { @Autowired private JedisClient jedisClient; @Value("${SESSION_EXPIRE}") private Integer SESSION_EXPIRE; @Override public E3Result getUserByToken(String token) { // 根据token查询redis String json = jedisClient.get("SESSION"+token); if(StringUtils.isBlank(json)){ // 如果查询不到数据,返回用户已经过期 return E3Result.build(400, "用户登录已经过期,请重新登录"); } // 如果查询到数据,说明用户已经登录 // 需要重置key的过期时间 jedisClient.expire("SESSION"+token, SESSION_EXPIRE); // 把json数据转换成user对象,然后使用e3Result包装并返回。 TbUser user = JsonUtils.jsonToPojo(json, TbUser.class); return E3Result.ok(user); } }
【Controller层】
-
请求的url:/user/token/{token}
-
参数:String token需要从url中取。
- 返回值:json数据。使用e3Result包装Tbuser对象。
Controller public class TokenController { @Autowired private TokenService tokenService; @RequestMapping("/user/token/{token}") @ResponseBody public Object getUserByToken(@PathVariable String token,String callback){ E3Result result = tokenService.getUserByToken(token); // 响应结果之前,判断是否为jsonp请求 if(StringUtils.isNotBlank(callback)){ // 把结果封装成一个js语句响应 MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result); mappingJacksonValue.setJsonpFunction(callback); return mappingJacksonValue; } return result; } }
2.5 首页展示用户名
功能分析:
-
当用户登录成功后,在cookie中有token信息。
-
从cookie中取token根据token查询用户信息。
-
把用户名展示到首页。
问题:服务接口在sso系统中。Sso.e3.com(localhost:8088),在首页显示用户名称,首页的域名是www.e3.com(localhost:8082),使用ajax请求跨域了。而JS不可以跨域请求数据。
什么是跨域:
- 域名不同
- 域名相同,端口号不同
解决js的跨域问题可以使用jsonp。
Jsonp不是新技术,跨域的解决方案。使用js的特性绕过跨域请求。JS可以跨域加载js文件。
2.6 Jsonp原理
2.7 Jsonp实现
【客户端】
var E3MALL = { checkLogin : function(){ var _ticket = $.cookie("token"); if(!_ticket){ return ; } $.ajax({ url : "http://localhost:8087/user/token/" + _ticket, dataType : "jsonp", type : "GET", success : function(data){ if(data.status == 200){ var username = data.data.username; var html = username + ",欢迎来到宜立方购物网!<a href=\"http://www.e3mall.cn/user/logout.html\" class=\"link-logout\">[退出]</a>"; $("#loginbar").html(html); } } }); } } $(function(){ // 查看是否已经登录,如果已经登录查询登录信息 E3MALL.checkLogin(); });
【服务端】
- 功能分析:
-
接收callback参数,取回调的js的方法名。
-
业务逻辑处理。
-
响应结果,拼接一个js语句。
-
- 实现方法一:
@RequestMapping(value="/user/token/{token}",produces=MediaType.APPLICATION_JSON_UTF8_VALUE"application/json;charset=utf-8") @ResponseBody public String getUserByToken(@PathVariable String token, String callback) { E3Result result = tokenService.getUserByToken(token); //响应结果之前,判断是否为jsonp请求 if (StringUtils.isNotBlank(callback)) { //把结果封装成一个js语句响应 return callback + "(" + JsonUtils.objectToJson(result) + ");"; } return JsonUtils.objectToJson(result); }
- 如果spring是4.1以上的版本,可以使用方法二:
@RequestMapping(value="/user/token/{token}") @ResponseBody public Object getUserByToken(@PathVariable String token, String callback) { E3Result result = tokenService.getUserByToken(token); //响应结果之前,判断是否为jsonp请求 if (StringUtils.isNotBlank(callback)) { //把结果封装成一个js语句响应 MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result); mappingJacksonValue.setJsonpFunction(callback); return mappingJacksonValue; } return result; }
参考博文:https://blog.csdn.net/kisscatforever/article/details/76409250