一、时序图流程梳理
1.在hosts文件配置域名
www.mtv.com、www.music.com作为前端系统。www.sso.com、www.cas.com作为后台登录管理系统。
二、构建CAS工程
1.主要配置
(1)加依赖
<!--sso cas 用spring构建登录页面-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
(2)在resource/templates下创建登录页面
(3)yml文件配置
spring:
thymeleaf:
mode: HTML
encoding: UTF-8
prefix: classpath:/templates/ #前缀 页面所在的位置
suffix: .html #后缀
(4)controller层编写
a.测试单点登录系统
@Controller //因为在这里会包含一个页面,所以不使用@RestController
public class SSOController {
@GetMapping("/login")
@ResponseBody //@RestController不用加@ResponseBody @Controller 需要加@ResponseBody,这样,会返回json字符串
public Object login() {
return "Hello World";
}
}
测试:
b.测试 登录前端系统,跳转至sso系统的登录页面
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller //因为在这里会包含一个页面,所以不使用@RestController
public class SSOController {
@GetMapping("/login")
//@ResponseBody //@RestController不用加@ResponseBody @Controller 需要加@ResponseBody,这样,会返回json字符串,测试时可以打开这个注解,然后方法里return "hello"
public Object login(String returnUrl,
Model model, //因为要把returnUrl传到templates/login.html页面,所以要加Model
HttpServletRequest request,
HttpServletResponse response) {
model.addAttribute("returnUrl",returnUrl);
//TODO 后续完善校验是否登录
//用户从未登录过,第一次进入则跳转到CAS的统一登录页面
return "login"; //返回templates/login.html页面
}
}
访问前端系统:
跳转至登录页面:www.sso.com
三、用户登录与校验、创建用户会话
1.代码实现 在SSOController里,路径、参数根据login.html里配置的,见二.1.(2)
@PostMapping("/doLogin")
public Object doLogin(String username,
String password,
String returnUrl,
Model model, //因为要把returnUrl传到templates/login.html页面,所以要加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";
}
//实现用户的redis会话 如果没有集成redis,而是集成了其他中间件,比如memecache,则保存到memecache
String uniqueToken = UUID.randomUUID().toString().trim();
//设置用户会话
UsersVO usersVO = new UsersVO();
BeanUtils.copyProperties(userResult,usersVO); //拷贝的时候,UsersVO没有的属性。比如密码,邮箱,手机号没有,userResult就不会把这些信息拷贝过去了
usersVO.setUserUniqueToken(uniqueToken);
redisOperator.set(REDIS_USER_TOKEN + ":" +userResult.getId(),
JsonUtils.objectToJson(usersVO));
return "login";
}
2.测试
(1) 访问www.mtv.com:8080/sso-mtv/ ,跳转至登录页面时,不输入用户名和密码
(2)访问www.mtv.com:8080/sso-mtv/ ,跳转至登录页面时,输入错误的用户名和密码
(3)访问www.mtv.com:8080/sso-mtv/ ,跳转至登录页面时, 输入正确的用户名和密码
(4)查看redis中保存的会话
四、 全局门票、临时票据
五、验证与销毁临时票据
六、换取用户会话
七、二次登录验证全局门票
八、实现注销
以上四到八的步骤代码实现如下,也就是一中整个流程图的实现。
package com.imooc.controller;
import com.imooc.pojo.Users;
import com.imooc.pojo.vo.UsersVO;
import com.imooc.service.UserService;
import com.imooc.utils.IMOOCJSONResult;
import com.imooc.utils.JsonUtils;
import com.imooc.utils.MD5Utils;
import com.imooc.utils.RedisOperator;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
@Controller //因为在这里会包含一个页面,所以不使用@RestController
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";
@GetMapping("/login")
//@ResponseBody //@RestController不用加@ResponseBody @Controller 需要加@ResponseBody,这样,会返回json字符串,测试时可以打开这个注解,然后方法里return "hello"
public String login(String returnUrl,
Model model, //因为要把returnUrl传到templates/login.html页面,所以要加Model
HttpServletRequest request,
HttpServletResponse response) {
model.addAttribute("returnUrl",returnUrl);
//1. 获取userTicket门票,如果cookie中能够获取到,证明用户登录过,此时签发一个一次性的临时门票
String userTicket = getCookie(COOKIE_USER_TICKET,request);
boolean isVerified = verifyUserTicket(userTicket);
if(isVerified){
String tmpTicket = createTmpTicket();
return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
}
//2. 用户从未登录过,第一次进入则跳转到CAS的统一登录页面
return "login"; //返回templates/login.html页面
}
/**
* 检验CAS全局用户门票
* @param userTicket
* @return
*/
private boolean verifyUserTicket(String userTicket){
//0. 验证CAS门票不能为空
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 Object doLogin(String username,
String password,
String returnUrl,
Model model, //因为要把returnUrl传到templates/login.html页面,所以要加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会话 如果没有集成redis,而是集成了其他中间件,比如memecache,则保存到memecache
String uniqueToken = UUID.randomUUID().toString().trim();
//设置用户会话
UsersVO usersVO = new UsersVO();
BeanUtils.copyProperties(userResult,usersVO); //拷贝的时候,UsersVO没有的属性。比如密码,邮箱,手机号没有,userResult就不会把这些信息拷贝过去了
usersVO.setUserUniqueToken(uniqueToken);
redisOperator.set(REDIS_USER_TOKEN + ":" +userResult.getId(),
JsonUtils.objectToJson(usersVO));
//3. 生成ticket门票,全局门票,代表用户在CAS端登录过
//trim() 去掉末尾和开始的空格
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: 用于颁发给用户进行一次性的验证的票据,有时效性
*/
/**
* 举例:
* 我们去动物园玩耍,大门口买了一张统一的门票,这个就是CAS系统的全局门票和用户全局会话。
* 动物园里有一些小的景点,需要凭你的门票去领取一次性的票据,有了这张票据以后就能去一些小的景点游玩了。
* 这样的一个个的小景点其实就是我们这里所对应的一个个的站点。
* 当我们使用完毕这张临时票据以后,就需要销毁。
*/
// return "login";
return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket; //回跳
}
@PostMapping("/verifyTmpTicket")
@ResponseBody //@RestController不用加@ResponseBody @Controller 需要加@ResponseBody,这样,会返回json字符串
public IMOOCJSONResult verifyTmpTicket(String tmpTicket,
HttpServletRequest request,
HttpServletResponse response) throws Exception{
//使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
//使用完毕后,需要销毁临时票据
String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET + ":" +tmpTicket);
if(StringUtils.isBlank(tmpTicketValue)){
return IMOOCJSONResult.errorUserTicket("用户票据异常");
}
//0. 如果临时票据OK,则需要销毁,并拿到CAS端cookie中的全局userTicket,以此再获取用户会话
if(!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))){
return IMOOCJSONResult.errorUserTicket("用户票据异常");
}else {
// 销毁临时票据
redisOperator.del(REDIS_TMP_TICKET + ":" +tmpTicket);
}
//1.验证并且获取用户的userTicket
String userTicket = getCookie(COOKIE_USER_TICKET,request);
String userId = redisOperator.get(REDIS_USER_TICKET + ":" + userTicket);
if(StringUtils.isBlank(userId)){
return IMOOCJSONResult.errorUserTicket("用户票据异常");
}
//2. 验证门票对应的user会话是否存在
String userRedis = redisOperator.get(REDIS_USER_TOKEN + ":" +userId);
if(StringUtils.isBlank(userRedis)){
return IMOOCJSONResult.errorUserTicket("用户票据异常");
}
//3. 验证成功,返回OK,携带用户会话
return IMOOCJSONResult.ok(JsonUtils.jsonToPojo(userRedis,UsersVO.class));
}
@PostMapping("/logout")
@ResponseBody //@RestController不用加@ResponseBody @Controller 需要加@ResponseBody,这样,会返回json字符串
public IMOOCJSONResult logout(String userId,
HttpServletRequest request,
HttpServletResponse response) throws Exception{
//0. 获取CAS中的用户门票
String userTicket = getCookie(COOKIE_USER_TICKET,request);
//1. 清除userTicket票据 redis/cookie
deleteCookie(COOKIE_USER_TICKET,response);
redisOperator.del(REDIS_USER_TICKET + ":" + userTicket);
//2.清除用户全局会话(分布式会话)
redisOperator.del(REDIS_USER_TOKEN + ":" + userId);
return IMOOCJSONResult.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 value,HttpServletResponse response){
Cookie cookie = new Cookie(key,value);
cookie.setDomain("sso.com");
cookie.setPath("/");
response.addCookie(cookie);
}
private String getCookie(String key,HttpServletRequest request){
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); //时间设置为-1
response.addCookie(cookie);
}
}
九、最终测试
访问www.mtv.com,首先跳转至www.sso.com站点,进行登录,登录成功后,跳转至原来的www.mtv.com站点
在同一台服务器下,访问www.music.com,可直接访问,因为刚刚已在www.mtv.com登录过,全局门票验证通过。
退出登录测试
退出登录针对的是某一个站点,也就是说www.music.com退出登录,www.mtv.com还是能访问。
退出登录,就会把全局门票的cookie清空