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端口;
依赖:
<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>
部分配置信息:
############################################################
#
# web访问端口号 约定:8088
#
############################################################
server:tomcat:uri-encoding: UTF-8max-http-header-size: 80KB############################################################
#
# 配置数据源信息
#
############################################################
spring:profiles:active: devdatasource: # 数据源的相关配置type: com.zaxxer.hikari.HikariDataSource # 数据源类型:HikariCPdriver-class-name: com.mysql.jdbc.Driver # mysql驱动username: roothikari: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分钟 1800000msconnection-test-query: SELECT 1servlet:multipart:max-file-size: 512000 #文件上传大小限制为500kbmax-request-size: 512000 #请求大小限制为500kb
# session:
# store-type: redisthymeleaf:mode: HTMLencoding: utf-8prefix: 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.MyMappernot-empty: false #在进行数据库操作的时候,判断表达式username! = null,是否追加username!=''identity: MYSQL
# 分页插件配置
pagehelper:helperDialect: mysqlsupportMethodsArguments: true
server:port: 8090spring:datasource: # 数据源的相关配置url: jdbc:mysql://localhost:3306/foodie-shop-dev?useUnicode=true&characterEncoding=UTF-8&autoReconnect=truepassword: xxxredis:#redis单机单实例database: 2host: 192.168.56.102port: 6379timeout: 5000password: xxx
启动类
@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
}
构建登录的模版页:
<!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>
@Controller
public class SSOController {@Autowiredprivate UserService userService;@Autowiredprivate 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")@ResponseBodypublic 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端锁签发的一个一次性的临时ticketString tmpTicket = createTmpTicket();/*** userTicket:用于表示用户在CAS端的一个登录状态:已经登录* tmpTicket:用于颁发给用户进行一次性的验证的票据,有时效性*/
// return "login";return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;}@PostMapping("/verifyTmpTicket")@ResponseBodypublic 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);}//验证并且获取用户的userTicketString 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")@ResponseBodypublic JSONResult logout(String userId,HttpServletRequest request,HttpServletResponse response){//0.获取CAS中的用户门票String userTicket = getCookie(request,COOKIE_USER_TICKET);//1.清除userTicket票据,redis/cookiedeleteCookie(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://github.com/apereo/cas
https://www.apereo.org/projects/cas
(备注:后续会将所有的代码传到github上,有兴趣的可以关注一下)