1.相同顶级域名的单点登录SSO
相同顶级域名的单点登录:SSO:SINGLE SIGN ON
单点登录可以通过基于用户会话的共享;分为两种,第一种:相同顶级域名;
原理是分布式会话完成的;关键是顶级域名的cookie值是可以共享的
比如说现在有个一级域名为 www.xxx.com,是教育类网站,但是xxx网有其他的产品线,可以通过构建二级域名提供服务给用户访问,比如:music.xxx.com,shop.xxx.com,blog.xxx.com等等,分别为xx音乐,xx电商以及xx博客等,用户只需要在其中一个站点登录,那么其他站点也会随之而登录。
也就是说,用户自始至终只在某一个网站下登录后,那么他所产生的会话,就共享给了其他的网站,实现了单点网站登录后,同时间接登录了其他的网站,那么这个其实就是单点登录,他们的会话是共享的,都是同一个用户会话。
Cookie + Redis 实现 SSO
那么之前我们所实现的分布式会话后端是基于redis的,如此会话可以流窜在后端的任意系统,都能获取到缓存中的用户数据信息,前端通过使用cookie,可以保证在同域名的一级二级下获取,那么这样一来,cookie中的信息userid和token是可以在发送请求的时候携带上的,这样从前端请求后端后是可以获取拿到的,这样一来,其实用户在某一端登录注册以后,其实cookie和redis中都会带有用户信息,只要用户不退出,那么就能在任意一个站点实现登录了。
那么这个原理主要也是cookie和网站的依赖关系,顶级域名 www.xxx.com和*.xxx.com的cookie值是可以共享的,可以被携带至后端的,比如设置为 .xxx.com,.t.xxx.com,如此是OK的。
二级域名自己的独立cookie是不能共享的,不能被其他二级域名获取,比如:music.xxx.com的cookie是不能被blog.xxx.com共享,两者互不影响,要共享必须设置为.xxx.com
2.不同顶级域名的单点登录
如果顶级域名都不一样,咋办?比如 wwww.xxx.com要和www.yyy.com的会话实现共享,这个时候又该如何?!
这个时候的cookie由于顶级域名不同,就不能实现cookie跨域了,每个站点各自请求到服务端,cookie无法同步。比如,www.xxx.com下的用户发起请求后会有cookie,但是他又访问了www.yyy.com,由于cookie无法携带,所以要求二次登录。
那么遇到顶级域名不同却又要实现单点登录该如何实现呢?我们来参考下面一张图:
如上图所示,多个系统之间的登录会通过一个独立的登录系统去做验证,它就相当于是一个中介公司,整合了所有人,你要看房经过中介允许拿钥匙就行,实现了统一的登录。那么这个就称之为CAS系统,CAS全称为Central Authentication Service即中央认证服务,是一个单点登录的解决方案,可以用于不同顶级域名之间的单点登录。构建两个静态站点来测试使用即可。
在CAS中的具体的流程参考如下时序图:
3.代码实现:
SSO-MTV;SSO-MUSIC为两个不同顶级域名的子系统;用于测试用的;运行在tomcat的8080端口;
依赖:
java
<dependencies>
<!-- 自定义Service -->
<dependency>
<groupId>com.nly</groupId>
<artifactId>foodie-dev-service</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
部分配置信息:
java
############################################################
#
# web访问端口号 约定:8088
#
############################################################
server:
tomcat:
uri-encoding: UTF-8
max-http-header-size: 80KB
############################################################
#
# 配置数据源信息
#
############################################################
spring:
profiles:
active: dev
datasource: # 数据源的相关配置
type: com.zaxxer.hikari.HikariDataSource # 数据源类型:HikariCP
driver-class-name: com.mysql.jdbc.Driver # mysql驱动
username: root
hikari:
connection-timeout: 30000 # 等待连接池分配连接的最大时长(毫秒),超过这个时长还没可用的连接则发生SQLException, 默认:30秒
minimum-idle: 5 # 最小连接数
maximum-pool-size: 20 # 最大连接数
auto-commit: true # 自动提交
idle-timeout: 600000 # 连接超时的最大时长(毫秒),超时则被释放(retired),默认:10分钟
pool-name: DateSourceHikariCP # 连接池名字
max-lifetime: 1800000 # 连接的生命时长(毫秒),超时而且没被使用则被释放(retired),默认:30分钟 1800000ms
connection-test-query: SELECT 1
servlet:
multipart:
max-file-size: 512000 #文件上传大小限制为500kb
max-request-size: 512000 #请求大小限制为500kb
# session:
# store-type: redis
thymeleaf:
mode: HTML
encoding: utf-8
prefix: classpath:/templates/
suffix: .html
############################################################
#
# mybatis 配置
#
############################################################
mybatis:
type-aliases-package: com.nly.pojo # 所有POJO类所在包路径
mapper-locations: classpath:mapper/*.xml # mapper映射文件
############################################################
#
# mybatis mapper 配置
#
############################################################
# 通用 Mapper 配置
mapper:
mappers: com.nly.my.mapper.MyMapper
not-empty: false #在进行数据库操作的时候,判断表达式username! = null,是否追加username!=''
identity: MYSQL
# 分页插件配置
pagehelper:
helperDialect: mysql
supportMethodsArguments: true
server:
port: 8090
spring:
datasource: # 数据源的相关配置
url: jdbc:mysql://localhost:3306/foodie-shop-dev?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true
password: xxx
redis:
#redis单机单实例
database: 2
host: 192.168.56.102
port: 6379
timeout: 5000
password: xxx
启动类
java
@SpringBootApplication
//扫描mybatis通用的包
@MapperScan(basePackages = "com.nly.mapper")
//扫描所有包以及相关组件包
@ComponentScan(basePackages = {"com.nly","org.n3r.idworker"})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class,args);
}
//ApplicationListener
}
构建登录的模版页:
java
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>SSO单点登录</title>
</head>
<body>
<h1>欢迎访问单点登录系统</h1>
<form action="doLogin" method="post">
<input type="text" name="username" placeholder="请输入用户名"/>
<input type="password" name="password" placeholder="请输入密码"/>
<input type="hidden" name="returnUrl" th:value="${returnUrl}">
<input type="submit" value="提交登录"/>
</form>
<span style="color:red" th:text="${errmsg}"></span>
</body>
</html>
java
@Controller
public class SSOController {
@Autowired
private UserService userService;
@Autowired
private RedisOperator redisOperator;
public static final String REDIS_USER_TOKEN = "redis_user_token";
public static final String REDIS_USER_TICKET= "redis_user_ticket";
public static final String REDIS_TMP_TICKET = "redis_tmp_ticket";
public static final String COOKIE_USER_TICKET = "cookie_user_ticket";
/*
@RequestMapping("/hello")
@ResponseBody
public Object hello(){
return "hello,world";
}*/
@RequestMapping("/login")
public String login(String returnUrl,
Model model,
HttpServletRequest request,
HttpServletResponse response){
model.addAttribute("returnUrl", returnUrl);
//从cookie中获取userTicket门票,如果cookie中能够获取到,说明用户登录过,签发tmpTicket即可
String userTicket = getCookie(request,COOKIE_USER_TICKET);
boolean isVerified = verifyUserTicket(userTicket);
if (isVerified) {
String tmpTicket = createTmpTicket();
return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
}
//用户从未登录过,第一次进入则跳转到CAS的统一登录页面
return "login";
}
private boolean verifyUserTicket(String userTicket){
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;
}
/**
* 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);
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();
/**
* userTicket:用于表示用户在CAS端的一个登录状态:已经登录
* tmpTicket:用于颁发给用户进行一次性的验证的票据,有时效性
*/
// return "login";
return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
}
@PostMapping("/verifyTmpTicket")
@ResponseBody
public JSONResult verifyTmpTicket(String tmpTicket,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
//使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
//使用完毕后,需要销毁临时票据
String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET+":"+tmpTicket);
if (StringUtils.isBlank(tmpTicketValue)){
return JSONResult.errorUserTicket("用户票据异常");
}
//0.如果临时票据ok,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获得用户会话
if(!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))){
return JSONResult.errorUserTicket("用户票据异常");
}else {
//销毁临时票据
redisOperator.del(REDIS_TMP_TICKET +":"+ tmpTicket);
}
//验证并且获取用户的userTicket
String userTicket = getCookie(request,COOKIE_USER_TICKET);
String userId = redisOperator.get(REDIS_USER_TICKET +":"+ userTicket);
if (StringUtils.isBlank(userId)){
return JSONResult.errorUserTicket("用户票据异常");
}
//2.验证门票对应的user会话是否存在
String userRedis =redisOperator.get(REDIS_USER_TOKEN+":"+userId);
if (StringUtils.isBlank(userRedis)) {
return JSONResult.errorUserTicket("用户票据异常");
}
//验证成功,返回ok,携带用户会话
return JSONResult.ok(JsonUtils.jsonToPojo(userRedis,UsersVO.class));
}
@PostMapping("/logout")
@ResponseBody
public JSONResult logout(String userId,
HttpServletRequest request,
HttpServletResponse response){
//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 JSONResult.ok();
}
/**
* 创建临时票据
* @return
*/
private String createTmpTicket(){
String tmpTicket =UUID.randomUUID().toString().trim();
try{
redisOperator.set(REDIS_TMP_TICKET+":"+tmpTicket,MD5Utils.getMD5Str(tmpTicket),600);
} catch (Exception e) {
e.printStackTrace();
}
return tmpTicket;
}
private void setCookie(String key,
String val,
HttpServletResponse response){
Cookie cookie = new Cookie(key,val);
cookie.setDomain("sso.com");
cookie.setPath("/");
response.addCookie(cookie);
}
private String getCookie(HttpServletRequest request,String key){
Cookie[] cookieList = request.getCookies();
if (cookieList == null || StringUtils.isBlank(key)){
return null;
}
String cookieValue = null ;
for (int i = 0 ; i < cookieList.length; i ++) {
if (cookieList[i].getName().equals(key)) {
cookieValue = cookieList[i].getValue();
break;
}
}
return cookieValue;
}
private void deleteCookie(String key,HttpServletResponse response){
Cookie cookie = new Cookie(key,null);
cookie.setDomain("sso.com");
cookie.setPath("/");
cookie.setMaxAge(-1);
response.addCookie(cookie);
}
}
那么对于SSO的整个处理流程来讲,其实我们实现起来并不是很难,主要是为的理解整个流程,因为在面试过程中有可能会被问到。如果有兴趣的同学,可以去参考一下Apereo的CAS系统,是非常牛的,地址如下:
https://www.apereo.org/projects/cas
(备注:后续会将所有的代码传到github上,有兴趣的可以关注一下)