前言
这篇文章熬了两天终于搞定了,最后的单点登录的代码着实有点绕,因为每次都要校验很多内容。 通过写这篇文章,我也解开了我心中的谜团:分布式系统中各个机器到底是怎样交互的???一、什么是分布式会话
1.会话
在servlet年代,当用户与服务器交互时,服务器tomcat会为用户创建一个session,并且前端会生成一个jsessionId,每次访问服务器都会携带。服务器在接受到请求时,就能接收到这个jsessionId,并根据Id在内存中找到对应的session,当拿到了session会话就可以进行操作了。在会话存活时,就可以认为用户一直在线,当会话超时,就可以认为用户已离开。用户的信息都通过会话来校验。

2.无状态会话
Http是无状态的,即服务端无法区分是哪个用户访问的。而cookie的出现就是为了有状态的记录用户。用户每次访问服务器时,可以携带一个token或者userId,这样就可以让服务器能够识别到用户的信息,变成有状态的。
3.为什么要用无状态会话
- 有状态会话都是放在服务器内存中的,当用户量过多,就会造成瓶颈。
- 无状态会话可以采用介质,前端使用cookie存储userId/userToken,后端可以使用redis。这样服务器的压力就小了。
4.集群分布式系统会话
对于阿里云控制台,它内部有许多不同的模块,我们以域名和云服务器为例。对于阿里这样访问量大的网站, 它肯定是把这两个模块放在不同的机器上的。
- 当我们进入云服务器时,(若之前没登陆过)要我们先去登录。这时就相当于我们上面所说的无状态会话中生成一个token
- 在登录进入云服务器这个模块后,点击域名模块可以直接跳转,并且不需要再次登录。这个过程就是前端根据cookie中的数据,请求后端,实现单点登录

(我觉得如果阿里云里面能跳到淘宝,就不用登录了)
集群分布式系统的会话模式:各个系统之间以redis作为介质,用户的token存在共享的redis里,用户浏览器存储cookie,实现有状态的会话。
二、项目中实现用户会话
目标1: 当用户登录/注册成功时,要为用户生成token,并存储在cookie和redis中。
目标2: 当用户要修改个人信息时,由于会涉及到cookie和redis内存储的内容,要更新redis和cookie
// 生成用户token
String uniqueToken = UUID.randomUUID().toString().trim();
// 存入redis会话
redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(), uniqueToken);
// 这里读者不用关心,是和业务有关的
// 由于cookie是浏览器可见的,不能将用户的所有信息都放在cookie中(不安全)
// 因此,只需要拿出一小部分属性即可,所以使用了一个VO(view object)
UsersVO usersVO = new UsersVO();
BeanUtils.copyProperties(userResult, usersVO);
usersVO.setUserUniqueToken(uniqueToken);
// 存入cookie中
CookieUtils.setCookie(request, response, "user", JsonUtils.objectToJson(usersVO), true);
目标3: 当用户退出登录时,要删除cookie和redis。
//清除cookie
CookieUtils.deleteCookie(request, response, "user");
// 分布式会话中需要清除用户数据
redisOperator.del(REDIS_USER_TOKEN + ":" + userId);
三、分布式会话拦截器
1.为什么要加拦截器?
先来看修改用户信息的代码:
@ApiOperation(value = "修改用户信息", notes = "修改用户信息", httpMethod = "POST")
@PostMapping("/update")
public IMOOCJSONResult update(
@ApiParam(name = "userId", value = "用户Id", required = true)
@RequestParam String userId,
@RequestBody @Valid CenterUserBO centerUserBO,
BindingResult errorResult,
HttpServletRequest request,
HttpServletResponse response) {
//BindingResult是否保存错误的验证信息
if (errorResult.hasErrors()) {
Map<String, String> errorMap = getErrors(errorResult);
return IMOOCJSONResult.errorMap(errorMap);
}
Users result = centerUserService.updateUserInfo(userId, centerUserBO);
// 添加token,整合进redis,分布式会话
UsersVO usersVO = conventUsersVO(result);
//token存入cookie
CookieUtils.setCookie(request, response, "user", JsonUtils.objectToJson(result), true);
return IMOOCJSONResult.ok();
}
设想一种情况:当一个非法用户解析到了这个API接口,然后他就有可能能够修改其他用户的信息。
因此我们需要在请求这个接口 之前加一层验证,来确保执行此接口的用户是本人。
2.怎样加拦截器
- 首先实现HandlerInterceptor接口,然后重写拦截器的三个方法(由于需求是在请求接口之前进行拦截校验,所以只需要使用第一个方法)
- 拦截原理:前端传来UserId和UserToken,后端用UserId去查找Token。
- 若前端UserId和UserToken为空,说明没登录
- 若后端从redis中查不到token,说明用户没登陆过
- 若后端从redis中查到了token但是和前端传来的不一样,说明后端的token已经更新了,这说明要么是非法用户,要么是用户又登录了一次,导致token刷新。这样可以直接返回用户在其他地方登录。
/**
* @author runze
* @create 2020/11/1
* @description
*/
public class UserTokenInterceptor implements HandlerInterceptor {
public static final String REDIS_USER_TOKEN = "redis_user_token";
@Autowired
private RedisOperator redisOperator;
/*
* 拦截请求在访问controller调用之前
* */
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/*
* false代表请求被拦截,被驳回,验证出了问题
* true校验通过,可放行
* */
String userId = request.getHeader("headerUserId");
String userToken = request.getHeader("headerUserToken");
//先看前端有没有ID和Token
if (StringUtils.isNotBlank(userId) && StringUtils.isNotBlank(userToken)) {
//从redis去拿userToken
String uniqueToken = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
//拿不到说明没登陆
if (StringUtils.isBlank(uniqueToken)) {
returnErrorResponse(response, IMOOCJSONResult.errorMsg("请登录..."));
return false;
} else {
//若拿到了但是和前端传来的不同,说明Token在redis中已经被更新了
if (!uniqueToken.equals(userToken)) {
returnErrorResponse(response, IMOOCJSONResult.errorMsg("账号在异地登录..."));
return false;
}
}
} else {
returnErrorResponse(response, IMOOCJSONResult.errorMsg("请登录..."));
return false;
}
// System.out.println("进入拦截器,被拦截");
return true;
}
public void returnErrorResponse(HttpServletResponse response, IMOOCJSONResult result) {
OutputStream ous = null;
try {
response.setCharacterEncoding("utf-8");
response.setContentType("text/json");
ous = response.getOutputStream();
ous.write(JsonUtils.objectToJson(result).getBytes("utf-8"));
ous.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (ous != null) {
ous.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/*
* 请求访问controller之后,渲染视图之前
* */
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
/*
* 请求访问controller之后,渲染视图之后
* */
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
现在仅仅是实现了拦截器的业务逻辑,还要将拦截器注册到系统中
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public UserTokenInterceptor userTokenInterceptor() {
return new UserTokenInterceptor();
}
/**
* 注册拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userTokenInterceptor())
.addPathPatterns("/hello")
.addPathPatterns("/shopcart/add")
.addPathPatterns("/shopcart/del")
.addPathPatterns("/address/list")
.addPathPatterns("/address/add")
.addPathPatterns("/address/update")
.addPathPatterns("/address/setDefalut")
.addPathPatterns("/address/delete")
.addPathPatterns("/orders/*")
.addPathPatterns("/center/*")
.addPathPatterns("/userInfo/*")
.addPathPatterns("/myorders/*")
.addPathPatterns("/mycomments/*")
.excludePathPatterns("/myorders/deliver")
.excludePathPatterns("/orders/notifyMerchantOrderPaid");
WebMvcConfigurer.super.addInterceptors(registry);
}
}
四、单点登录SSO(Single Sign On)
单点登录分为两种:相同顶级域名下的单点登录 和 不同顶级域名下的单点登录
相同域名下的单点登录
考虑上文中阿里云的例子,从阿里云去访问域名服务

CSDN中的例子



可以看到,这三个网站都是CSDN的子网站,在跳转时都不需要进行二次密码输入验证,打开控制台可以看到,两个网址中都存在UserToken这个Cookie键,值是相同的。(这里并不是要强调这个键,可以看到其他的键值对也有许多是相同的,因为他们之间是共享的。)

可以看到,两个网站的顶级域名相同,并且,在我从阿里云跳转到域名时,不需要进行二次验证。
这种情况下可以通过Redis+cookie实现单点登录,它的原理是:
- 顶级域名下,例如A.aliyun.com和B.aliyun.com是可以共享的,因为他们都在aliyun.com域名下,浏览器在两个页面中可以共享aliyun.com的cookie
- 而对于A.A.aliyun.com和B.B.aliyun.com是不能共享的,他们的A.aliyun.com和B.aliyun.com下的cookie是不同的。
- 当cookie可以共享时,可以共享用户登录的token,传给后端,后端在redis找到响应的token,即可以登录成功。
不同域名下的单点登录CAS(Central Authentication Service)
当在两个不同域名下进行单点登录时,由于cookie中的内容不能共享,所以,上面的方法就变成了无状态的会话。
对于下图所示,当用户登录了abc.com这时候他要跳转到123.com,由于没有携带cookie信息,所以要二次登录。
若还要进行单点登录,可以考虑添加一个单独的系统 CAS(Central Authentication Service) 即中央认证服务。(这里的CAS可不是Java并发中的CAS(Compare And Set))
CAS的时序图
在介绍整个流程之前,我们要先了解四个值:
- redis_user_token:代表用户会话信息,只要用户登录,就在redis中创建一个用户会话
- redis_user_ticket:给用户创建的一个全局门票,代表用户在CAS登录过。
- cookie_user_ticket:存在cookie中的一个全局门票,其含义同redis_user_ticket
- redis_tmp_ticket:给用户创建的一个临时门票,这个票据用来回跳到原来的站点,并且使用完后就要销毁掉
这里门票可以这样理解:我们去一个景区,在进景区的大门时,景区给我们一个大的门票,这个门票就相当于redis_user_ticket,即全局门票,然后当我们进入一个个小的景点,或者坐观光车时,也会有一个临时门票,那个门票就是redis_tmp_ticket,一次性使用,用完后就要销毁掉,就相当于我们访问一个子网站,访问时生成,访问成功了就销毁了。
1.当用户访问时,子系统首先会验证是否登录,携带returnUrl到CAS系统中进行验证。
- returnUrl是什么?当跳转到验证界面后验证成功后,还要重新跳回到原来页面,returnUrl就是在验证完成后要跳回的地方。
@GetMapping("/login")
public String login(String returnUrl,
Model model,
HttpServletRequest request,
HttpServletResponse response) {
model.addAttribute("returnUrl", returnUrl);
// 1. 获取userTicket门票,如果cookie中能够获取到,证明用户登录过,此时签发一个一次性的临时票据并且回跳
String userTicket = getCookie(request, COOKIE_USER_TICKET);
boolean isVerified = verifyUserTicket(userTicket);
//当用户登录过,就可以直接返回
if (isVerified) {
String tmpTicket = createTmpTicket();
return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
}
// 2. 用户从未登录过,第一次进入则跳转到CAS的统一登录页面
return "login";
}
2.若未登陆过,则会进入CAS登录页面进行账号密码登录,输入账号密码后,CAS进行校验,并创建相应票据
/**
* CAS的统一登录接口
* 目的:
* 1. 登录后创建用户的全局会话 -> uniqueToken
* 2. 创建用户全局门票,用以表示在CAS端是否登录 -> userTicket
* 3. 创建用户的临时票据,用于回跳回传 -> tmpTicket
*/
@PostMapping("/doLogin")
public String doLogin(String username,
String password,
String returnUrl,
Model model,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
model.addAttribute("returnUrl", returnUrl);
// 0. 判断用户名和密码必须不为空
if (StringUtils.isBlank(username) ||
StringUtils.isBlank(password)) {
model.addAttribute("errmsg", "用户名或密码不能为空");
return "login";
}
// 1. 实现登录,不正确就直接返回登录页面
Users userResult = userService.queryUserForLogin(username,
MD5Utils.getMD5Str(password));
if (userResult == null) {
model.addAttribute("errmsg", "用户名或密码不正确");
return "login";
}
// 2. 实现用户的redis会话
String uniqueToken = UUID.randomUUID().toString().trim();
UsersVO usersVO = new UsersVO();
BeanUtils.copyProperties(userResult, usersVO);
usersVO.setUserUniqueToken(uniqueToken);
// 将REDIS_USER_TOKEN存入redis,这个是代表用户会话,里面存储用户的信息和Token
redisOperator.set(REDIS_USER_TOKEN + ":" + userResult.getId(),
JsonUtils.objectToJson(usersVO));
// 3. 生成ticket门票,全局门票,代表用户在CAS端登录过
String userTicket = UUID.randomUUID().toString().trim();
// 3.1 用户全局门票需要放入CAS端的cookie中
setCookie(COOKIE_USER_TICKET, userTicket, response);
// 4. userTicket关联用户id,并且放入到redis中,代表这个用户有门票了,可以在各个景区游玩
redisOperator.set(REDIS_USER_TICKET + ":" + userTicket, userResult.getId());
// 5. 生成临时票据,回跳到调用端网站,是由CAS端所签发的一个一次性的临时ticket
//这个临时门票只能用这一次,在跳转后就失效
String tmpTicket = createTmpTicket();
return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
}
/**
* 创建临时票据
*
* @return
*/
private String createTmpTicket() {
String tmpTicket = UUID.randomUUID().toString().trim();
try {
// 过期时间600s
redisOperator.set(REDIS_TMP_TICKET + ":" + tmpTicket,
MD5Utils.getMD5Str(tmpTicket), 600);
} catch (Exception e) {
e.printStackTrace();
}
return tmpTicket;
}
3.若已经登陆过
- 从cookie中获取到cookie_user_ticket(全局门票)
- 判断cookie_user_ticket是否有效 --> redis中是否存在redis_user_ticket
- 验证会话是否存在(能否找到uredis_user_token)
/**
* 校验CAS全局用户门票co
*
* @param userTicket
* @return
*/
private boolean verifyUserTicket(String userTicket) {
// 0. 验证CAS门票不能为空
if (StringUtils.isBlank(userTicket)) {
return false;
}
// 1. 验证CAS门票是否有效
String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
if (StringUtils.isBlank(userId)) {
return false;
}
// 2. 验证门票对应的user会话是否存在
String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
if (StringUtils.isBlank(userRedis)) {
return false;
}
return true;
}
4.子系统请求CAS对临时ticket进行校验,CAS校验完成后,删除tmp_ticket
@PostMapping("/verifyTmpTicket")
@ResponseBody
public IMOOCJSONResult verifyTmpTicket(String tmpTicket,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
// 使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
// 使用完毕后,需要销毁临时票据
String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" + tmpTicket);
if (StringUtils.isBlank(tmpTicketValue)) {
return IMOOCJSONResult.errorUserTicket("tmpTicketValue为空,用户票据异常");
}
// 0. 如果临时票据OK,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获取用户会话
if (!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))) {
return IMOOCJSONResult.errorUserTicket("票据不一致,用户票据异常");
} else {
// 销毁临时票据
redisOperator.del(REDIS_TMP_TICKET + ":" + tmpTicket);
}
// 1. 验证并且获取用户的userTicket
String userTicket = getCookie(request, COOKIE_USER_TICKET);
String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
if (StringUtils.isBlank(userId)) {
return IMOOCJSONResult.errorUserTicket("userId为空,用户票据异常"+userTicket);
}
// 2. 验证门票对应的user会话是否存在
String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" + userId);
if (StringUtils.isBlank(userRedis)) {
return IMOOCJSONResult.errorUserTicket("Redis中没了,用户票据异常");
}
// 验证成功,返回OK,携带用户会话
return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userRedis, UsersVO.class));
}
作为一名高尚的后端程序员,我们要善于去看前端逻辑
5.注销登录
- 清除cookie、redis的userTicket
- 清除用户会话userToken
@PostMapping("/logout")
@ResponseBody
public IMOOCJSONResult logout(String userId,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
// 0. 获取CAS中的用户门票
String userTicket = getCookie(request, COOKIE_USER_TICKET);
// 1. 清除userTicket票据,redis/cookie
deleteCookie(COOKIE_USER_TICKET, response);
redisOperator.del(REDIS_USER_TICKET + ":" + userTicket);
// 2. 清除用户全局会话(分布式会话)
redisOperator.del(REDIS_USER_TOKEN + ":" + userId);
return IMOOCJSONResult.ok();
}