生活中的事情
最近购物的时候遇到一个很奇妙的情况,我发现我只在天猫登录了,之后去淘宝买东西的时候,完全不虚要登录,这是为什么?
首先我去天猫登录一下,之后刷新淘宝来看看,登录天猫之后,直接去刷新淘宝页面
我们会发现淘宝也登录了,为什么可以这么方便呢?这里就用到了单点登录的概念。
什么是单点登录
单点登录在大型网站里使用得非常频繁,例如,阿里旗下有淘宝、天猫、支付宝,阿里巴巴,等网站,还有背后的成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协 作,如果每个子系统都需要用户认证,不仅用户会疯掉,各子系统也会为这种重复认证授权的逻辑搞疯掉。
单点登录在大型网站里使用得非常频繁,例如,阿里旗下有淘宝、天猫、支付宝,阿里巴巴,阿里妈妈, 阿里妹妹等网站,还有背后的成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协 作,如果每个子系统都需要用户认证,不仅用户会疯掉,各子系统也会为这种重复认证授权的逻辑搞疯 掉。
所以,单点登录要解决的就是,用户只需要登录一次就可以访问所有相互信任的应用系统。
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO 并 不能算是一种架构,只能说是一个解决方案。SSO核心意义就一句话:一处登录,处处登录;一处注 销,处处注销。就是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统,即 用户只需要记住一组用户名和密码就可以登录所有有权限的系统。
使用“单点登录”还是SOA时代的需求之一。在面向服务的架构中,服务和服务之间,程序和程序 之间的通讯大量存在,服务之间的安全认证是SOA应用的难点之一,应此建立“单点登录”的系统体系能 够大大简化SOA的安全问题,提高服务之间的合作效率。
简谈SSO的发展
早期的单机部署
早期我们开发web应用都是所有的包放在一起打成一个war包放入tomcat容器来运行的,所有的功能,所 有的业务,后台管理,门户界面,都是由这一个war来支持的,这样的单应用,也称之为单体应用,因为十分不好 扩展和拆分。
在单体应用下,用户的登录以及权限就显得十分简单:过滤器,用户登录成功后,把相关信息放入会话 中,HTTP维护这个会话,再每次用户请求服务器的时候来验证这个会话即可
验证登录的这个会话就是session,维护了用户状态,也就是所谓的HTTP有状态协议,我们经常可以在浏览 器中看到JSESSIONID,这个就是用来维持这个关系的key。
分布式集群部署
由于网站的访问量越来也大,单机部署已经是巨大瓶颈,所以才有了后来的分布式集群部署。
例如:如 果引入集群的概念,1单应用可能重新部署在3台tomcat以上服务器,使用nginx来实现反向代理, 此时,这个 session就无法在这3台tomcat上共享,用户信息会丢失,所以不得不考虑多服务器之间的session同步问 题,这就是单点登录的来源。
多种解决方案
单点登录的实现方案,一般就包含以下三种:
- Cookies
- Session同步
- 分布式Session方式
目前的大型网站都是采用分布式Session的方式。我先从cookie的实现谈起,你就能很清楚的知道为什 么需要分布式session方式实现单点登录。
基于Cookie的单点登录
最简单的单点登录实现方式,是使用cookie作为媒介,存放用户凭证。 用户登录父应用之后,应用返回 一个加密的cookie,当用户访问子应用的时候,携带上这个cookie,授权应用解密cookie并进行校验, 校验通过则登录当前用户。
不难发现以上方式把信任存储在客户端的Cookie中,这种方式很容易令人质疑:
- Cookie不安全
- 不能跨域实现免登
对于第一个问题,通过加密Cookie可以保证安全性,当然这是在源代码不泄露的前提下。如果Cookie 的加密算法泄露,攻击者通过伪造Cookie则可以伪造特定用户身份,这是很危险的。 对于第二个问题,不能跨域实现免登更是硬伤。所以,才有了以下的分布式session方案
分布式session单点登录
我们这次就是基于redis的单点登录
例如,阿里有很多系统分割为多个子系统,独立部署后,不可避免的会遇到会话管理的问题,类似这样 的电商网站一般采用分布式Session实现。 再进一步可以根据分布式Session+redis,建立完善的单点登录或账户管理系统。
流程运行:
- 用户第一次登录时,将会话信息(用户Id和用户信息),比如以用户Id为Key,写入分布式 Session;
- 用户再次登录时,获取分布式Session,是否有会话信息,如果没有则调到登录页
- 一般采用Cache中间件实现,建议使用Redis,因此它有持久化功能,方便分布式Session宕机后, 可以从持久化存储中加载会话信息;
- 存入会话时,可以设置会话保持的时间,比如15分钟,超过后自动超时;
结合Cache中间件实现的分布式Session,可以很好的模拟Session会话。
常见方案
实现单点登录说到底就是要解决如何产生和存储那个信任,再就是其他系统如何验证这个信任的有效 性,
因此要点也就以下两个:
- 存储信任 服务器生产
- 验证信任 ~ 拿到服务器再次验证~
身份认证技术:
- cas(单点登录)
- Spring Security OAuth2(第三方登录授权:QQ登陆)
- jwt (客户端token:原生)
安全控制框架:
- spring-security
- shiro
设计思路与环境
环境
JDK 11
maven3.6.2
redis 6
springboot 2.x
设计与编写思路
单点登录验证中心:
- islogin:验证是否有token,并且设置对应user的限时key
- checklogin:检查有没有token,没有的话就带着请求地址转发到login登录,如果有token就返回token给客户端做二次请求校验
- verify: 校验token是否正确,返回布尔值
- 注销
客户端思路:
- 请求工具封装
- 拦截器:http请求验证是否有登陆过,如果没有转发到检查登录
- 服务器登录完整之后会转发会客户端,拦截器判断是否有token参数,获取token请求验证中心二次验证,通过的话放行
功能流程图
SS0_server主要实现
客户端拦截器请求处理:
拦截器:请求islogin判断有没有登录
server:返回登录判断结果,如果检查到redis有token就生成对应的限时userkey
拦截器:如果没有登陆过,并且token为null那么就请求checklofin
server:检查没有token,代表第一次请求转发到login登录并且返回携带token返回
拦截器:检测到token请求二次校验,
server:返回与redis和tokenmap的校验结果
拦截器:根据结果判断是否重新请求checklogin或者通过
下面代码实现上述流程的server处理
服务端核心功能
logincontroller
- islogin:验证是否有token,并且设置对应user的限时key
- checklogin:检查有没有token,没有的话就带着请求地址转发到login登录,如果有token就返回token给客户端做二次请求校验
- verify: 校验token是否正确,返回布尔值
- 注销
/** * @projectName: SSO_self * @package: com.hyc.sso_server.controller * @className: loginController * @author: 冷环渊 doomwatcher * @description: TODO * @date: 2022/1/9 12:44 * @version: 1.0 */ @Controller public class loginController { @Autowired StringRedisTemplate redisTemplate; @RequestMapping("/index") public String login() { return "login"; } UserAndPassword userAndPassword = new UserAndPassword(); @RequestMapping("/login") public String login(String username, String password, String redirectUrl, RedirectAttributes model) { System.out.println("loginuser" + "\t" + username + "\t" + "password" + "\t" + password); //模拟登录数据库设计 if ("admin".equals(username) && "123456".equals(password)) { //校验成功 // 1. 创建token String token = UUID.randomUUID().toString(); System.out.println("token创建成功==>" + token); // 2. 需要把令牌信息收入到数据库中 //if (redisTemplate.hasKey("token")) { // redisTemplate.delete("token"); //} redisTemplate.opsForSet().add("token", token); userAndPassword.setUsername(username); userAndPassword.setPassword(password); userTokenMap.tokenmap.put(username, token); // 3.重定向到redirecturl model.addAttribute("token", token); model.addAttribute("username", username); System.out.println(redirectUrl); return "redirect:" + redirectUrl; } //如果密码不正确,重新的返回登录页面 System.out.println("用户密码不正确,重复回到登录页面"); model.addAttribute("redirectUrl", redirectUrl); return "login"; } //检查登录 @RequestMapping("/checkLogin") public String checklogin(String redirectUrl, Model model) { String getgenretetoken = userTokenMap.tokenmap.get(userAndPassword.getUsername()); Set<String> token = redisTemplate.opsForSet().members("token"); if (!token.contains(getgenretetoken)) { // 没有全局会话的话 跳转带统一认证中心的登陆界面 // 携带转发来的地址 一起转发回去 model.addAttribute("redirectUrl", redirectUrl); return "login"; } else { // 全局会话 取出令牌信息 重定向到 redirecturl model.addAttribute("token", getgenretetoken); model.addAttribute("username", userAndPassword.getUsername()); return "redirect:" + redirectUrl; } } @RequestMapping("/islogin") @ResponseBody public String islogin() { String getgenretetoken = userTokenMap.tokenmap.get(userAndPassword.getUsername()); Set<String> token = redisTemplate.opsForSet().members("token"); if (!token.contains(getgenretetoken)) { return "false"; } else { String islogin = null; if (redisTemplate.hasKey(userAndPassword.getUsername())) { islogin = redisTemplate.opsForValue().get(userAndPassword.getUsername()); } else { islogin = String.valueOf(RedisUtils.cacheValue(userAndPassword.getUsername(), "true", 30)); } return islogin; } } //校验token 是否是验证中心产生 @RequestMapping("/verify") @ResponseBody public String verifytoken(String token) { if (redisTemplate.opsForSet().isMember("token", token)) { System.out.println("服务端token验证ok===>" + token); return "true"; } return "false"; } @RequestMapping("/loginOut") @ResponseBody public String loginout() { RedisUtils.redisTemplate.delete("token"); RedisUtils.redisTemplate.delete("islogin"); System.out.println("删除成功"); return "注销成功"; } }
redis监听器
检查userkey是否过期,redis触发过期时间就删除对应的token
配置:
@Configuration public class RedisConfiguration { @Autowired private RedisConnectionFactory redisConnectionFactory; //配置 将spring的 redis链接工厂放入监听容器 @Bean public RedisMessageListenerContainer redisMessageListenerContainer() { RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer(); redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory); return redisMessageListenerContainer; } //将自定义的监听器放入容器中 @Bean public keyexpireListener keyExpiredListener() { return new keyexpireListener(this.redisMessageListenerContainer()); } }
自定义监听器
/** * @projectName: SSO_self * @package: com.hyc.sso_server.Listener * @className: keyexpireListener * @author: 冷环渊 doomwatcher * @description: TODO * @date: 2022/1/11 17:30 * @version: 1.0 */ public class keyexpireListener extends KeyExpirationEventMessageListener { //日志 private static final Logger LOGGER = LoggerFactory.getLogger(keyexpireListener.class); /** * @param listenerContainer must not be {@literal null}. */ public keyexpireListener(RedisMessageListenerContainer listenerContainer) { super(listenerContainer); } //实现信息方法,打印监听日志,后续可能对过期key处理 /*redis key 过期:pattern=__keyevent@*__:expired,channel=__keyevent@0__:expired,key=name * 测试完毕 * */ @Override public void onMessage(Message message, byte[] pattern) { String channel = new String(message.getChannel(), StandardCharsets.UTF_8); //过期的key String key = new String(message.getBody(), StandardCharsets.UTF_8); // 如果登录令牌过期,则删除token 要求用户重新登录 for (String s : userTokenMap.tokenmap.keySet()) { if (key.equals(s)) { RedisUtils.redisTemplate.opsForSet().remove("token", userTokenMap.tokenmap.get(key)); System.out.println("删除成功"); } } LOGGER.info("redis key 过期:pattern={},channel={},key={}", new String(pattern), channel, key); } }
客户端核心
SSO请求工具
public class SSOClientUtil { private static Properties ssoProperties = new Properties(); public static String SERVER_URL_PREFIX; //统一认证中心地址 public static String CLIENT_HOST_URL; //当前客户端地址 static { try { ssoProperties.load(SSOClientUtil.class.getClassLoader().getResourceAsStream("sso.properties")); } catch (IOException e) { e.printStackTrace(); } SERVER_URL_PREFIX = ssoProperties.getProperty("server-url-prefix"); CLIENT_HOST_URL = ssoProperties.getProperty("client-host-url"); } /** * 当客户端请求被拦截,跳往统一认证中心,需要带redirectUrl的参数,统一认证中心登录后回调的地址 */ public static String getRedirectUrl(HttpServletRequest request) { //获取请求URL return CLIENT_HOST_URL + request.getServletPath(); } /** * 根据request获取跳转到统一认证中心的地址,通过Response跳转到指定的地址 */ public static void redirectToSSOURL(HttpServletRequest request, HttpServletResponse response) throws IOException { String redirectUrl = getRedirectUrl(request); StringBuilder url = new StringBuilder(50) .append(SERVER_URL_PREFIX) .append("/checkLogin?redirectUrl=") .append(redirectUrl); response.sendRedirect(url.toString()); } public static void redirectToSSOURL(HttpServletRequest request, HttpServletResponse response, String token) throws IOException { String redirectUrl = getRedirectUrl(request); StringBuilder url = new StringBuilder(50) .append(SERVER_URL_PREFIX) .append("/checkLogin?redirectUrl=") .append(redirectUrl) .append("&token=") .append(token); response.sendRedirect(url.toString()); } /** * 获取客户端的完整登出地址 */ public static String getClientLogOutUrl() { return CLIENT_HOST_URL + "/loginOut"; } /** * 获取认证中心的登出地址 */ public static String getServerLogOutUrl() { return SERVER_URL_PREFIX + "/loginOut"; } }
对应配置文件
server-url-prefix=http://www.sso.com:8001 client-host-url=http://www.tm.com:8003
http请求工具
/** * @projectName: SSO_self * @package: com.hyc.http * @className: HttpUitl * @author: 冷环渊 doomwatcher * @description: TODO * @date: 2022/1/9 0:52 * @version: 1.0 */ public class HttpUitl { public static String sendHttpRequest(String httpurl, Map<String, String> parms) throws Exception { //定义需要访问的地址 URL url = new URL(httpurl); //连接 URL HttpURLConnection connect = (HttpURLConnection) url.openConnection(); //请求方式 connect.setRequestMethod("POST"); //携带参数 connect.setDoOutput(true); if (parms != null && parms.size() > 0) { StringBuffer sb = new StringBuffer(); for (Map.Entry<String, String> par : parms.entrySet()) { sb.append("&").append(par.getKey()).append("=").append(par.getValue()); } connect.getOutputStream().write(sb.substring(1).toString().getBytes("UTF-8")); } // 发起请求 connect.connect(); //接受返回值 String response = StreamUtils.copyToString(connect.getInputStream(), Charset.forName("UTF-8")); return response; } }
拦截器
- 请求校验
- 二次校验
- 拦截判断
/** * @projectName: SSO_self * @package: com.hyc.ssoclienttb.interceptor * @className: ssoInterceptor * @author: 冷环渊 doomwatcher * @description: TODO * @date: 2022/1/9 20:20 * @version: 1.0 */ public class ssoInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String httpuri = SSOClientUtil.SERVER_URL_PREFIX + "/islogin"; HashMap<String, String> isloginparms = new HashMap<>(); String islogin = HttpUitl.sendHttpRequest(httpuri, isloginparms); if (islogin.equals("true")) { return true; } //判断地址栏是否有携带token参数 String token = request.getParameter("token"); System.out.println(token); if (!StringUtils.isEmpty(token)) { System.out.println("检测到token信息,需要去sso服务器验证!"); // token 信息不为空 说明拥有令牌,我们需要去认证中心检查这个令牌,以防伪造 String httpURL = SSOClientUtil.SERVER_URL_PREFIX + "/verify"; HashMap<String, String> parms = new HashMap<>(); //放入登出的位置和token parms.put("token", token); try { String isverify = HttpUitl.sendHttpRequest(httpURL, parms); if ("true".equals(isverify)) { // 如果返回的字符串是true 说明这个token是由统一认证中心产生的 System.out.println("检测到token信息,验证通过"); return true; } else { SSOClientUtil.redirectToSSOURL(request, response); return false; } } catch (Exception e) { e.printStackTrace(); } } //不存在用通过我们的认证工具类 转发到对应的认证界面 ssoclientuitl SSOClientUtil.redirectToSSOURL(request, response); return false; }
实现效果
此时redis的keys的状态
这里我们启动三个项目
访问两个客户端
- http://www.sso.com:8001/checkLogin?redirectUrl=http://www.tm.com:8003/tmall
- http://www.sso.com:8001/checkLogin?redirectUrl=http://www.tb.com:8002/taobao
可以看出我们这两个都是没有登陆过的,接下来我们登录其中一个客户端
此时查看redis状态
之后刷新另一个
会发现淘宝客户端登录之后,天猫不需要登录,也可以直接进入首页。