SpringCloud入门 —— SSO 单点登录
前言
本文适合初学者,如有不足或错误之处,还请大家在下方留言指正。(文章稍长,建议点赞收藏)
一、SSO单点登录是什么?
单点登录简介
单点登录SSO (Single Sign On) 是指在一个多系统共存的环境下,用户在一处登录后,就不用在其他系统中登录,也就是用户的一次登录能得到其他所有系统的信任。
单系统登录
在之前我们做的单系统登录
,它的核心是Cookie,Cookie携带会话id在浏览器与服务器之间维护会话状态。
Cookie 和 Session
众所周知,HTTP是无状态的协议
,这意味着服务器无法确认用户的信息。于是乎,W3C就提出了:给每一个用户都发一个通行证,无论谁访问的时候都需要携带通行证,这样服务器就可以从通行证上确认用户的信息。通行证就是Cookie
。
如果说Cookie是检查用户身上的”通行证“来确认用户的身份,那么Session
就是通过检查服务器上的”客户明细表“来确认用户的身份的。Session相当于在服务器中建立了一份“客户明细表”。
HTTP协议是无状态的,Session不能依据HTTP连接来判断是否为同一个用户。于是乎:服务器向用户浏览器发送了一个名为JESSIONID
的Cookie,它的值是Session的id值。其实Session是依据Cookie来识别是否是同一个用户。
那它们之间有什么区别呢?
Cookie 一般用来保存用户信息(数据保存在客户端(浏览器端))
①我们在 Cookie 中保存已经登录过得用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了;
②一般的网站都会有保持登录也就是说下次你再访问网站的时候就不需要重新登录了,这是因为用户登录的时候我们可以存放了一个 Token 在 Cookie 中,下次登录的时候只需要根据 Token 值来查找用户即可(为了安全考虑,重新登录一般要将 Token 重写);
③登录一次网站后访问网站其他页面不需要重新登录。
Session 的主要作用就是通过服务端记录用户的状态(数据保存在服务器端)
典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。
单系统登录流程
:
1.用户登录时,验证用户的账户和密码
2.生成一个Token保存在数据库中,将Token写到Cookie中
3.将用户数据保存在Session中
4.请求时都会带上Cookie,检查有没有登录,如果已经登录则放行
多系统登录
虽然单系统登录有多种完美的解决方案,但对于多系统应用群已经不再适用了,为什么呢?
主要存在以下几个问题:
- Session不共享问题
- Cookie跨域的问题
其实两个问题很类似
Session不共享:很容易理解,多系统即可能有多个Tomcat,而Session是依赖当前系统的Tomcat,所以系统A的Session和系统B的Session是不共享的。
Cookie的域(通常对应网站的域名),浏览器发送http请求时会自动携带与该域匹配的Cookie,而不是所有Cookie
问题解决
Session不共享问题:
- 使用广播机制将Session复制到各个服务器
- 把Session数据放在Redis中(使用Redis模拟Session)
Cookie跨域的问题:
- 客户端对Cookie进行解析,将Token解析出来,此后请求都把Token带上
- 多个域名共享Cookie,在写到客户端的时候设置Cookie的domain。
登录流程
上面的问题解决,一个简单的单点登录就已经完成了,来看看它的登录流程吧
对于上图的说明:
1.用户访问系统A受保护资源,系统A发现用户并没有登录,于是重定向到sso认证中心,并将自己的地址作为参数
2.sso认证中心发现用户未登录,将用户引导至登录页面
3.用户进行输入用户名和密码进行登录,用户与认证中心建立全局会话(生成一份token,写到cookie中,保存在浏览器上)
4.sso认证中心带着token跳转回系统A
5.系统A去sso认证中心验证这个token,系统A和用户建立局部会话(创建Session)。系统A已登录
6.用户访问系统B的受保护资源,重定向到sso认证中心,并将自己的地址作为参数
7.认证中心根据带过来的Cookie发现已经与用户建立了全局会话了,认证中心重定向回系统B,并把Token携带过去给系统B
8.系统B去sso认证中心验证这个token,系统B和用户建立局部会话(创建Session)。系统B已登录
注销
有了登录,肯定就有注销登录。单点登录自然也要单点注销,在一个子系统中注销,所有子系统的会话都将被销毁
首先要了解,全局会话和局部会话的约束关系:
- 局部会话存在,全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话必须销毁
注销流程:
1.用户发起注销请求
2.系统A根据用户与系统A建立的会话id拿到令牌,向sso认证中心发起注销请求
3.sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
4.sso认证中心向所有注册系统发起注销请求
5.各注册系统接收sso认证中心的注销请求,销毁局部会话
6.sso认证中心引导用户至登录页面
二、代码示例
创建项目
上章讲了动态路由zuul,我们紧接上章,通过zuul配合redis实现单机版的sso单点登录。
首先创建一个springboot模块sso-server,并在eureka上注册
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
接口创建
这里验证token,只是简单判断了一下是否存在
import com.local.springboot.sso.ssoserver.util.AuthUtil;
import com.local.springboot.sso.ssoserver.util.RedisUtil;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RestController
@RequestMapping("/sso")
@SuppressWarnings("all")
public class LoginController {
@Autowired
private AuthUtil authUtil;
@Autowired
private RedisUtil redisUtil;
/**
* 验证用户登录令牌是否有效
*
* @param accessToken 登录令牌
* @return true 有效、false 无效
*/
@PostMapping("/checkAccessToken/{accessToken}")
public boolean checkAccessToken(@PathVariable("accessToken") String accessToken) {
return authUtil.checkAccessToken(accessToken);
}
/**
* 用户登录界面
*
* @param url
* @return
*/
@RequestMapping("/toLogin")
public ModelAndView toLogin(String url) {
ModelAndView modelAndView = new ModelAndView("login");
modelAndView.addObject("url", url);
return modelAndView;
}
/**
* 用户认证登录
*
* @param response HttpServletResponse
* @param userName 用户
* @param password 密码
* @param url 服务器请求url
* @return 认证结果、重定向请求
*/
@RequestMapping("/login")
public String login(HttpServletResponse response, String userName, String password, String url) {
// 用户认证,并生成Token
String accessToken = authUtil.checkUser(userName, password);
if (StringUtils.isNotBlank(accessToken)) {
try {
// 与用户建立全局会话(将Token写到cookie中)
Cookie cookie = new Cookie("accessToken", accessToken);
cookie.setMaxAge(60 * 3);
//设置访问路径
cookie.setPath("/");
response.addCookie(cookie);
// 重定向请求
response.sendRedirect(url);
} catch (IOException e) {
e.printStackTrace();
}
}
return "认证失败";
}
}
用户认证
import com.local.springboot.sso.ssoserver.entity.TSysUserEntity;
import com.local.springboot.sso.ssoserver.serice.TSysUserService;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @author: hzl
* @date: 2021-09-14 18:13
* @Description: 用户认证工具类
**/
@Component
@SuppressWarnings("all")
public class AuthUtil {
@Autowired
private RedisUtil redisUtil;
@Autowired
private TSysUserService userService;
/**
* 验证用户名和密码;并返回登录令牌
*
* @param userName 用户名
* @param password 密码
* @return
*/
public String checkUser(String userName, String password) {
String accessToken = "";
// 判断用户名和密码是否正确
TSysUserEntity entity = userService.getUserByName(userName);
if (entity != null) {
String dbPwd = entity.getPassword();
String pwd = Md5Util.MD5(password);
if (StringUtils.equals(dbPwd, pwd)) {
// 用户名+时间戳加密生成登录令牌、存放redis
String md5Str = userName + System.currentTimeMillis();
accessToken = Md5Util.MD5(md5Str);
// 登录令牌为key、存储用户信息(过期时间3分钟)
redisUtil.set(accessToken, entity.getId(), 3 * 60);
}
}
return accessToken;
}
public static void main(String[] args) {
System.out.println(Md5Util.MD5("123456"));
}
/**
* 验证用户登录令牌是否有效
*
* @param accessToken 登录令牌
* @return true 有效、false 无效
*/
public boolean checkAccessToken(String accessToken) {
return redisUtil.hasKey(accessToken);
}
public static String getLoginUserId() {
return null;
}
}
application.properties配置文件
server.port=11033
spring.application.name=sso-server
eureka.client.serviceUrl.defaultZone=http://localhost:8080/eureka/
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/local_develop?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=root
#关闭页面缓存
spring.thymeleaf.cache=false
#thymeleaf访问根路径
spring.thymeleaf.prefix=classpath:/thymeleaf/
spring.thymeleaf.mode=LEGACYHTML5
spring.redis.host=127.0.0.1
spring.redis.database=0
spring.redis.port=6379
spring.redis.password=
spring.redis.timeout=1000
spring.redis.jedis.pool.max-active=200
spring.redis.jedis.pool.max-wait=-1
spring.redis.jedis.pool.max-idle=10
spring.redis.jedis.pool.min-idle=0
zuul-server
访问分布式系统的任意请求,被Zuul的Filter拦截过滤,修改过滤器逻辑,用户登录验证请求sso认证中心
import com.local.springboot.zuul.zuulserver.feign.SsoFeign;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
/**
* 自定义过滤器
*/
public class MyAccessFilter extends ZuulFilter {
@Qualifier("ssoFeignFallback")
@Autowired
private SsoFeign feign;
/**
* 过滤器类型,可选值有 pre、route、post、error。
*
* @return
*/
@Override
public String filterType() {
return "pre";
}
/**
* 通过int值来定义过滤器的执行顺序
* 过滤器的执行顺序,数值越小,优先级越高。
*
* @return
*/
@Override
public int filterOrder() {
return 0;
}
/**
* 是否执行该过滤器,true 为执行,false 为不执行
* 这个也可以利用配置中心来实现,达到动态的开启和关闭过滤器。
* 配置文件中禁用过滤器:
* 【zuul.过滤器的类名.过滤器类型.disable=true,如:zuul.MyAccessFilter.pre.disable=true】
*
* @return
*/
@Override
public boolean shouldFilter() {
return false;
}
/**
* 过滤器具体逻辑
*
* @return
* @throws ZuulException
*/
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
HttpServletResponse response = ctx.getResponse();
// 获取cookie里面的accessToken值
String accessToken = "";
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
accessToken = cookie.getValue();
}
}
}
// 请求url地址
String url = getUrl(request);
// 过滤登录接口、登录页面、若带登录令牌,则验证令牌是否有效;有效则表示为登录用户
if (url.contains("/sso-server/sso/toLogin") || url.contains("/sso-server/sso/login") ||
((StringUtils.isNotBlank(accessToken)) && feign.checkAccessToken(accessToken))) {
// 标识为登录用户
ctx.setSendZuulResponse(true);
ctx.setResponseStatusCode(200);
} else {
// 标识为未登录用户,跳转至sso认证中心,并将自己的地址作为参数
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(302);
try {
response.sendRedirect("http://localhost:8088/sso-server/sso/toLogin?url=" + url);
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
private String getUrl(HttpServletRequest request) {
// 请求url初始化
StringBuilder url = new StringBuilder(request.getRequestURL().toString());
// 请求方式
String method = request.getMethod();
if ("GET".equals(method)) {
// GET请求拼接参数
url.append("?");
// 参数集合
Map<String, String[]> parameterMap = request.getParameterMap();
Object[] keys = parameterMap.keySet().toArray();
for (int i = 0; i < keys.length; i++) {
// 参数名
String key = (String) keys[i];
// 参数值
String value = parameterMap.get(key)[0];
url.append(key).append("=").append(value).append("&");
}
url.delete(url.length() - 1, url.length());
}
return url.toString();
}
}
修改路由配置,映射sso-server
zuul.routes.sso-server.path=/sso-server/**
zuul.routes.sso-server.service-id=sso-server
#Zuul丢失Cookie的解决方案:
zuul.sensitive-headers=
这里偷了个懒,并没有做各个子系统创建session的步骤…
测试
启动服务(不要忘了Redis服务)
如图:
浏览器访问 http://localhost:8088/client-customer/feign/testRibbon,被zuul-server拦截重定向到sso-server登录页面
输入用户名和密码,登录失败,返回提示;登录成功,则重定向到之前的请求
再次访问http://localhost:8088/client-provider/api/provider,成功访问
可以看到cookie及过期时间
3分钟后我们再次访问 cookie、Redis失效,需要重新登录
到此,简单的sso单点登录就已经完成。
涉及以往文章:
SpringBoot —— 简单整合Redis实例
SpringCloud入门 —— Feign服务调用
SpringCloud入门 —— Zuul路由配置
« 上一章:SpringCloud入门 —— Zuul路由配置
创作不易,关注、点赞就是对作者最大的鼓励,欢迎在下方评论留言
欢迎关注微信公众号:键指JAVA,定期分享Java知识,一起学习,共同成长。