这次废话少说,直接扔干货,自己也是刚刚学习的单点登录,如有问题,还请评论进行一起探讨。
一、单系统登录机制
1、http无状态协议
web应用采用的B/S架构,http作为通信协议。由于http本身是无状态协议,不存储任何登录信息,所以每次登录系统服务器之间都无任何关联,服务器都会进行独立处理操作。从网上盗个图来给大家进行解释下,下图将进行这个过程说明: 这也就意味着任何用户都可以进行访问服务器资源,如果想进行保护服务器的资源,那么就需要进行限制对服务器的请求;要想限制对服务器的请求,那么就需要进行鉴别服务器的请求,响应合法请求,忽略非法请求;要想进行鉴别是否合法请求,那么就需要清楚浏览器的状态。由于http协议无状态,那么就需要浏览器和服务器进行共同维护一个状态,这就是会话机制。
2、会话机制
当浏览器第一次进行请求服务器时,服务器会创建一个会话,并将会话id作为响应浏览器的一部分,浏览器并且会存储这个会话id;当浏览器进行第二次第三次第n次进行访问浏览器时,请求上会带上会话id,服务器得到会话id时会进行判断是不是同一个用户,后续请求就会和第一次产生了关联;下图就会进行说明: 服务器将在内存中进行保存会话id,那么浏览器中将在哪里保存呢,它有两种:
1、在请求参数中进行保存会话id
2、在cookie中进行保存
将会话id作为每一个请求的参数,服务器接收请求并且能进行解析获得会话id,来进行判断是否是同一用户,很明显的可以看出这种方式不安全。那就用浏览器自身来进行维护会话id,每发一次请求时都进行自动发送会话id,cookie机制正好可以来做这件事,cookie本身就是一种来进行存储少量数据的机制,数据以“key/value”键值对来进行存储,浏览器请求时自动带上cookie信息。
Tomcat会话机制当然也实现了cookie,访问tomcat服务器时,浏览器中会看到一个名为“JSESSIONID”的cookie,这个就是浏览器维护的会话id,如图所示:
3、登录状态
刚刚学习了会话机制,那么登录状态就很容易明白,我们当浏览器第一次请求时,输入用户名和密码,会将用户名和密码拿去跟数据库进行比较,比较正确的话,将记录为合法用户,否则记录为非法用户,合法用户将进行标记为“已授权”或“已登录”等状态,既然是会话状态,那么自然保存在会话对象中,tomcat会话在会话对象中设置登录状态如下:
HttpSession session = request.getSession();
session.setAttribute("isLogin", true);
用户再次登录时,tomcat将在会话对象中查看会话对象:
HttpSession session = request.getSession();
session.getAttribute("isLogin");
二、多系统的复杂性
随着时代的进步,单系统时代已经成为了历史,现如今已经发展成为了复杂的多系统组成的应用群,面对如此多的子系统,当用户登录和退出的时候,难道需要一个一个的登录和退出吗,这种方式肯定是不可取的。如下图所示: web系统已经由单系统发展成为复杂的多系统应用群,这个复杂性应该由系统内部承担,而非用户进行承担复杂性。无论系统拥有多么多的子系统多么复杂,对用户来说都是一个整体,也就是说,用户访问web系统应用群应该像访问单系统一样,进行登录和退出一次就行。 单系统解决方案核心是cookie,cookie携带的会话id在浏览器和服务器之间维护会话状态,但是cookie是有限制的,这个限制就是域(通常对应网站的域名)的问题,浏览器发送http请求时会自动携带与该域匹配的cookie,并不是所有的cookie 也许此时你们会说,可以使用一个共同的顶级域,也就是全部将域名设置成“*.baidu.com”,这种方法理论上可以的,甚至在早期有一些应用群都是这样解决的,但是实际中并不好,首先域名要进行统一,然后应用群要使用的技术都一致,要相同,不然cookie的key不相同,无法维持会话,并且无法实现宽平台语言的开发;第三:cookie本身是不安全的。
因此,需要单点登录来进行解决。
三、单点登录
单点登录的全称为Single Sign On(简称SSO),单点登录就是在多系统集群的情况下只需要进行登录其中一个系统,那么其他系统就可以得到授权无需再次登录,包括单点登录和单点注销两个部分。
相比较与单系统来说,SSO需要一个独立的认证中心,只有认证中心能够接受用户名和密码等安全信息,其他系统将不再提供注册登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,认证中心判断是合法的用户会创建令牌,在接下来跳转的过程中,认证中心会将令牌作为参数发送给各个系统,子系统拿到令牌,即得到授权,可以借此创建局部会话,局部会话登录方式和单系统的登录方式相同。这也就是单点登录系统的原理,如图所示: 下面对上图进行分析解释:
- 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户未登录,将用户引导至登录页面
- 用户输入用户名密码提交登录申请
- sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
- sso认证中心带着令牌跳转会最初的请求地址(系统1)
- 系统1拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统1
- 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
- 用户访问系统2的受保护资源
- 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
- 系统2拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统2
- 系统2使用该令牌创建与用户的局部会话,返回受保护资源
当用户登录成功时,会与SSO认证中心及各个子系统建立会话,用户与SSO认证中心建立的会话俗称为全局会话,用户与各个子系统创建的会话称为局部会话,局部会话创建后,用户可以访问子系统的受保护的资源,通过sso认证中心,全局会话和局部会话有如下关系:
- 局部会话存在,那么全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话一定销毁
2、 注销
单点登录自然需要单点注销。一个系统进行注销,那么所有系统都将进行注销。下图将进行说明: SSO认证中心一直监听全局会话状态,一旦全局会话销毁,那么监听器将通知所有注册系统执行注销操作。
下面将上图进行解释说明:
- 用户向系统1发起注销请求
- 系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求
- sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
- sso认证中心向所有注册系统发起注销请求
- 各注册系统接收sso认证中心的注销请求,销毁局部会话
- sso认证中心引导用户至登录页面
四、部署图
单点登录涉及sso认证中心与众子系统,子系统与sso认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须集成sso的客户端,sso认证中心则是sso服务端,整个单点登录过程实质是sso客户端与服务端通信的过程,用下图描述
五、实现
1、总体描述
我先总体介绍下我所实现的内容,这个应用群中包含有四个系统,分别为登录系统,主页系统,VIP系统和购物车系统。大致思路为:用户访问任意一个系统受保护资源时,会进行判断是否有访问令牌(是否登录),如果登录了则允许访问,如果未登录则直接拦截进行跳转至登录页面,进行登录,然后输入用户名和密码进行验证,如果正确,则生成令牌,分发给各个子系统,然后会进行跳转到刚刚用户所在的系统,然后验证其令牌,如果令牌正确,则可以进行访问受保护的系统资源。代码大致如下,创建的工程为gradle工程,使用的是template,大家也可以改成maven工程和前后端分离的项目。
gradle的配置代码如下所示:
buildscript {
repositories {
mavenLocal()
mavenCentral()
}
ext{
springBootVersion = '2.1.3.RELEASE'
}
dependencies {
classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}"
}
}
subprojects {
group 'com.sso'
version '1.0-SNAPSHOT'
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'org.springframework.boot'
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
compile 'org.springframework.boot:spring-boot-starter-web'
annotationProcessor 'org.projectlombok:lombok:1.18.2'
compileOnly 'org.projectlombok:lombok:1.18.2'
compile 'org.springframework.boot:spring-boot-starter-thymeleaf'
}
}
2、登录系统(sso-login)
LoginController.java代码如下图所示:
注意:此代码未进入数据库,模拟数据和进行验证,此子系统端口号为9000,记得在yml中进行配置端口号。
package com.sso.login.controller;
import com.sso.login.utils.LoginCacheUtil;
import com.sso.pojo.User;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* @Author: 闫高岭同志
* @Date: 2020/9/13 21:45
* @Version 1.0
*/
@Controller
@RequestMapping("/login")
public class LoginController {
//模拟用户数据
private static Set<User> dbUsers;
static {
dbUsers = new HashSet<>();
dbUsers.add(new User(0,"zhangsan","zhangsan"));
dbUsers.add(new User(1,"lisi","lisi"));
dbUsers.add(new User(2,"wangwu","wangwu"));
}
@PostMapping
public String doLogin(User user , HttpSession session , HttpServletResponse response){
System.out.println("4444444:"+user);
//记录从哪个页面跳转的网址
String target = (String) session.getAttribute("target");
//模拟从数据库中通过登录的用户名和密码去查找数据库中的用户
Optional<User> first = dbUsers.stream().filter(dbUser -> dbUser.getUsername().equals(user.getUsername()) &&
dbUser.getPassword().equals(user.getPassword())).findFirst();
//判断用户是否登录
if (first.isPresent()){
//保存用户登录信息
//随机生成token,也就是令牌
String token = UUID.randomUUID().toString();
Cookie cookie = new Cookie("TOKEN", token);
//解决跨域问题,注意127.0.0.1地址映射问题
cookie.setDomain("codeshop.com");
response.addCookie(cookie);
//将信息存储在loginUser中
LoginCacheUtil.loginUser.put(token,first.get());
}else {
//登录失败
session.setAttribute("msg","用户名或密码错误");
return "login";
}
//重定向到target地址
return "redirect:"+target;
}
@GetMapping("info")
@ResponseBody
public ResponseEntity<User> getUserInfo(String token){
if (!StringUtils.isEmpty(token)){
User user = LoginCacheUtil.loginUser.get(token);
return ResponseEntity.ok(user);
}else {
return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
}
}
}
ViewController.java代码入下图所示:
package com.sso.login.controller;
import com.sso.login.utils.LoginCacheUtil;
import com.sso.pojo.User;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpSession;
/**
* @Author: 闫高岭同志
* @Date: 2020/9/13 21:48
* @Version 1.0
*/
//页面跳转逻辑
@Controller
@RequestMapping("/view")
public class ViewController {
/**
* 跳转到登录页面
* @return
*/
@GetMapping("/login")
//target可能为空,用@RequestParam注解去设置,cookie也是这种情况
private String toLogin(@RequestParam(required = false,defaultValue = "") String target, HttpSession session, @CookieValue(required = false,value = "TOKEN") Cookie cookie){
//如果target为空,则设置成主页网址,最后往主页进行跳转
if (StringUtils.isEmpty(target)){
target = "http://www.codeshop.com:9010";
}
if (cookie != null){
//如果是已经登录的用户再次访问登录系统时,就要重定向
String value = cookie.getValue();
User user = LoginCacheUtil.loginUser.get(value);
if (user != null){
return "redirect:"+target;
}
}
//重定向地址
session.setAttribute("target",target);
return "login";
}
}
工具类如图所示:
登录子系统启动类:
user.java实体类如图所示:
登录页面HTML如图所示:
2、主页面系统(sso-main)
主页面系统总架构:
端口号是9010,记得到配置文件中进行更改哦!
ViewController.java代码如图所示:
package com.sso.main.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpSession;
import java.util.Map;
/**
* @Author: 闫高岭同志
* @Date: 2020/10/12 11:13
* @Version 1.0
*/
@Controller
@RequestMapping("/view")
public class ViewController {
@Autowired
private RestTemplate restTemplate;
private final String LOGIN_INFO_ADDRESS = "http://login.codeshop.com:9000/login/info?token=";
@GetMapping("/index")
public String toIndex(@CookieValue(required = false ,value = "TOKEN")Cookie cookie, HttpSession session){
if (cookie != null){
String token = cookie.getValue();
//判断是否登录
if (!StringUtils.isEmpty(token)){
//取出登录用户且且存入进session
Map result = restTemplate.getForObject(LOGIN_INFO_ADDRESS + token, Map.class);
session.setAttribute("loginUser",result);
}
}
return "index";
}
}
启动类代码如图所示:
注意这里采用的是Spring中的RestTemplate模板类,大家可以自行百度。
主页面显示如下图所示:
3、VIP系统
系统总体架构如图所示:
子系统端口号为9011,记得在配置文件更改哦。
viewController.java代码如图所示:
package com.sso.vip.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpSession;
import java.util.Map;
/**
* @Author: 闫高岭同志
* @Date: 2020/10/12 11:22
* @Version 1.0
*/
@Controller
@RequestMapping("/view")
public class ViewController {
@Autowired
protected RestTemplate restTemplate;
private final String USER_INFO_ADDRESS = "http://login.codeshop.com:9000/login/info?token=";
@GetMapping("/index")
public String toIndex(@CookieValue(required = false,value = "TOKEN") Cookie cookie,
HttpSession session) {
if (cookie != null){
String token = cookie.getValue();
if (!StringUtils.isEmpty(token)){
Map result = restTemplate.getForObject(USER_INFO_ADDRESS + token, Map.class);
session.setAttribute("loginUser",result);
}
}
return "index";
}
}
启动类(vipapp.java)如图所示:
package com.sso.vip;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
/**
* @Author: 闫高岭同志
* @Date: 2020/10/12 11:20
* @Version 1.0
*/
@SpringBootApplication
public class VipApp {
public static void main(String[] args) {
SpringApplication.run(VipApp.class,args);
}
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
index.html页面如图所示:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Vip</title>
</head>
<body>
<h1>欢迎来到VIP系统</h1>
<span>
<a th:if="${session.loginUser == null}" href="http://login.codeshop.com:9000/view/login?target=http://vip.codeshop.com:9011/view/index">登录</a>
<a th:if="${session.loginUser != null}" href="#">退出</a>
</span>
<p th:unless="${session.loginUser == null}">
<span style="color : red;" th:text="${session.loginUser.username}">已登录</span>
</p>
</body>
</html>
4、购物车系统(sso-cart)
总体概括如下图所示:
viewController.java如图所示:
package com.sso.cart.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpSession;
import java.util.Map;
/**
* @Author: 闫高岭同志
* @Date: 2020/10/12 11:32
* @Version 1.0
*/
@Controller
@RequestMapping("view")
public class ViewController {
@Autowired
protected RestTemplate restTemplate;
private final String USER_INFO_ADDRESS = "http://login.codeshop.com:9000/login/info?token=";
@GetMapping("index")
public String toIndex(@CookieValue(required = false,value = "TOKEN") Cookie cookie,
HttpSession session){
if (cookie != null){
String token = cookie.getValue();
if (!StringUtils.isEmpty(token)){
Map result = restTemplate.getForObject(USER_INFO_ADDRESS+token, Map.class);
session.setAttribute("loginUser",result);
}
}
return "index";
}
}
CartApp.java启动类如下图所示:
package com.sso.cart;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
/**
* @Author: 闫高岭同志
* @Date: 2020/10/12 11:24
* @Version 1.0
*/
@SpringBootApplication
public class CartApp {
public static void main(String[] args) {
SpringApplication.run(CartApp.class,args);
}
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
cart页面(index.html)如图所示:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Cart</title>
</head>
<body>
<h1>欢迎来到Cart页面</h1>
<span>
<a th:if="${session.loginUser == null}" href="http://login.codeshop.com:9000/view/login?target=http://cart.codeshop.com:9012/view/index">登录</a>
<a th:if="${session.loginUser != null}" href="#">退出</a>
</span>
<p th:unless="${session.loginUser == null}">
<span style="color : red;" th:text="${session.loginUser.username}"></span>已登录
</p>
</body>
</html>
好的,单点登录到此结束,单点退出还没实现,后期有时间的话会进行实现单点退出,思路也就是销毁cookie就行,大家可以先进行试试。
与人方便,与己方便
加油,奥利给