之前在项目中用到了单点登录系统来解决分布式环境中Session共享的问题,趁着现在闲了,总结一下......
什么是sso系统
SSO英文全称Single Sign On,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。它包括可以将这次主要的登录映射到其他应用中用于同一个用户的登录的机制。它是目前比较流行的企业业务整合的解决方案之一。
为什么要有单点登录系统
针对这个问题,我就拿现有的一个项目说一下吧,这个项目不仅涉及到集群,还涉及到了分布式。
先不说分布式,就单单拿集群来说,就会存在一个问题,比如,我这次访问网站,进行了登录,过一会之后我访问个人中心,Nginx将我的请求发到了另一台服务器,这时就出现问题了,这台服务器中并没没有保存我的登录状态,因而我需要重新登录,然后我又访问一次个人中心,Nginx又将我的请求发送到另一台服务器,然后...又提醒我登录,这当然是不能忍的,对于这种情况,除了搭建单点登录系统,还有一个解决方案,就是在Tomcat中配置Session复制,配置好了之后,tomcat会以广播的形式共享Session信息,但是这大大地增加了Tomcat的压力,而且存在一个问题,当你的集群中节点数量不断增加,就会出现问题,session共享太占用系统资源了,所以一般不选择这个作为解决方案。分布式环境下的登录问题就更不用说了,和集群中类似......这个时候我们就需要搭建一个单点登录系统,提供一个接口,共其他模块调用,以检测用户登录状态。
SSO单点登录系统说白了就是使用redis模拟Session(Key-Value),实现Session的统一管理。
具体实现
SSO表现层
定义了三个处理器,分别用于注册、登录、外部调用,查看用户登录状态:
RegisterController.java
package cn.e3mall.sso.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.pojo.TbUser;
import cn.e3mall.sso.service.RegisterService;
/**
* 注册功能Controller
*/
@Controller
public class RegisterController {
@Autowired
private RegisterService registerService;
@RequestMapping("/page/register")
public String showRegister() {
return "register";
}
@RequestMapping("/user/check/{param}/{type}")
@ResponseBody
public E3Result checkData(@PathVariable String param, @PathVariable Integer type) {
return registerService.checkData(param, type);
}
@RequestMapping(value="/user/register", method=RequestMethod.POST)
@ResponseBody
public E3Result saveUser(TbUser user) {
return registerService.saveUser(user);
}
}
这个没什么好说的,就是调用服务进行用户注册,将注册信息插入数据库中。
LoginController.java
package cn.e3mall.sso.controller;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import cn.e3mall.common.utils.CookieUtils;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.sso.service.LoginService;
@Controller
public class LoginController {
@Autowired
private LoginService loginService;
@Value("${TOKEN_KEY}")
private String TOKEN_KEY;
@RequestMapping("/page/login")
public String showLogin() {
return "login";
}
@RequestMapping(value="/user/login", method=RequestMethod.POST)
@ResponseBody
public E3Result login(String username, String password, HttpServletRequest request, HttpServletResponse response) {
E3Result e3Result = loginService.userLogin(username, password);
// 判断是否登录成功
if(e3Result.getStatus() == 200) {
String token = (String) e3Result.getData();
CookieUtils.setCookie(request, response, TOKEN_KEY, token);// 将token保存在cookie中,浏览器关闭即失效(类似sessionid)
}
// 如果登录成功,需要将token写入cookie
return e3Result;
}
}
这里调用了用户登录服务:
@Override
public E3Result userLogin(String username, String password) {
// 1、判断用户名和密码是否正确
TbUserExample example = new TbUserExample();
Criteria criteria = example.createCriteria();
criteria.andUsernameEqualTo(username);
// 执行查询
List<TbUser> userList = userMapper.selectByExample(example);
if (userList == null || userList.size() == 0) {
// 返回登录失败
return E3Result.build(400, "用户名或密码错误");
}
// 取用户信息
TbUser user = userList.get(0);
if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(user.getPassword())) {
// 返回登录失败
return E3Result.build(400, "用户名或密码错误");
}
// 3、如果正确生成token
String token = UUID.randomUUID().toString();
// 4、把用户信息写入redis,key:token value:用户信息
user.setPassword(null);
jedisClient.set("SESSION:" + token, JsonUtils.objectToJson(user));
jedisClient.expire("SESSION:" + token, SESSION_EXPIRE);
// 5、把token返回
return E3Result.ok(token);
}
这样,登录成功的话,服务层在Redis中会存在一个String类型的数据,并且这个数据设置了生存时间(30分钟),模拟了Session的生存时间,数据的key为SESSION:随机串,模仿的是sessionid,value为用户的信息(不包含密码)。表现层将服务层返回的token保存在cookie中,用于后续查询用户登录状态以及登录用户信息。
另外需要注意的是,cookie也存在跨域问题,即不能跨三级域名,但是可以跨二级域名,比如sso.code4j.cn和www.code4j.cn和search.code4j.cn之间的cookie是互通的,需要设置一下cookie.setDomain(.code4j.cn);即可
TokenController.java
package cn.e3mall.sso.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import cn.e3mall.common.utils.E3Result;
import cn.e3mall.common.utils.JsonUtils;
import cn.e3mall.sso.service.TokenService;
/**
* 根据token查询用户信息Controller
*
* @author Ldd
*
*/
@Controller
public class TokenController {
@Autowired
private TokenService tokenService;
/*
@RequestMapping(value = "/user/token/{token}", produces = "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);
}
*/
@RequestMapping("/user/token/{token}")
@ResponseBody
public Object getUserByToken(@PathVariable String token, String callback) {
E3Result result = tokenService.getUserByToken(token);
// 响应结果之前判断是否为jsonp请求
if (StringUtils.isNotBlank(callback)) {
// 响应Jsonp请求(使用于4.1版本以上的Spring)
MappingJacksonValue mappingJacksonValue = new MappingJacksonValue(result);
mappingJacksonValue.setJsonpFunction(callback);
return mappingJacksonValue;
}
return result;
}
}
这里调用了服务层的getUserByToKen方法:
@Override
public E3Result getUserByToken(String token) {
// 从redis中获取用户信息
String user_json = jedisClient.get("SESSION:" + token);
// 判断
if(StringUtils.isBlank(user_json)) {
return E3Result.build(201, "用户登录信息已过期");
} else {
TbUser user = JsonUtils.jsonToPojo(user_json, TbUser.class);
// 重置过期时间
jedisClient.expire("SESSION:" + token, SESSION_EXPIRE);
// 返回结果
return E3Result.ok(user);
}
}
这个处理器用于外部调用以检查用户登录状态,并获取用户登录信息,外部以/user/token/xxxxxxx的形式进行访问,xxxxx即为本地cookie中的token(sessionid),然后服务层从Redis中查询,查询到则表示用户已经登录,然后返回用户信息,并重置Redis中该数据的生存时间。
调用SSO单点登录系统
具体需求:
1.当用户登录之后,本地cookie中就会存在token信息。
2.从cookie中取token并根据token查询用户信息。
3.把用户名展示到首页
解决方案:
一、在Controller中取cookie中的token数据,调用sso服务查询用户信息
二、当页面加载完成后使用js取token的数据,使用ajax请求查询用户信息
这里我决定选择第二个方案,应为工程很多,如果使用第一种方案,需要更改每一个工程的Controller,而使用第二种解决方案,写一个js文件,然后在需要的地方引用即可,但是这里就存在一个问题,我们的服务接口在sso系统中sso.code4j.cn, 而首页的域名是www.code4j.cn使用ajax请求跨域了,而Js不可以跨域请求数据
什么是跨域:
1.域名不同
2.域名相同但端口不同
Js跨域的解决方案:Jsonp
Jsonp并不是什么新技术,而是跨域的解决方案。使用js的特性:Js可以跨域加载js文件,绕过跨域请求。
Jsonp原理:

看起来很复杂,其实真正使用的时候并不用这么麻烦,因为Jquery已经封装好了......
具体实现
客户端
var E3MALL = {
checkLogin : function(){
var _ticket = $.cookie("token");//获取cookie中的信息,使用了jquery.cookie.js
if(!_ticket){
return ;
}
$.ajax({
url : "http://localhost:8088/user/token/" + _ticket,
dataType : "jsonp",// 表示跨域请求,加了这个,上面图示的一些动作jq就会自动完成,你只需在处理器中接收callback然后处理即可
type : "GET",
success : function(data){
if(data.status == 200){
var username = data.data.username;
var html = username + ",欢迎您!<a href=\"http://www.code4j.cn/user/logout.html\" class=\"link-logout\">[退出]</a>";
$("#loginbar").html(html);
}
}
});
}
}
$(function(){
// 查看是否已经登录,如果已经登录查询登录信息
E3MALL.checkLogin();
});
这样就行了,获取到用户信息,就展示在首页顶部,没有接收到用户信息,就不做处理,还显示原来的登录按钮。
当然,这只是一个最基础的示范,具体业务还需具体分析,具体实现......