单点登陆——CAS流程
在单点登陆——Session共享中,我们介绍了使用Session共享的方式来实现单点登录,但是最后我们发现其也是存在一些弊端的,其最大的问题就是必须要求所有的系统在同级域名下。其实就是利用了Cookie顶域共享的特性。
那么如果是不同域呢?不同域之间Cookie是不共享的,怎么办?这里我们就要说一说CAS流程了,这个流程是单点登录的标准流程。
下面对上图简要描述,如下:
- 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数(用于登录后跳转返回)
- sso认证中心发现用户未登录,将用户引导至登录页面
- 用户输入用户名密码提交登录申请
- sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
- sso认证中心带着令牌跳转会最初的请求地址(系统1)
- 系统1拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统1
- 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
- 用户访问系统2的受保护资源
- 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
- 系统2拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统2
- 系统2使用该令牌创建与用户的局部会话,返回受保护资源
上述就是我们CAS中的主要两种情况,看着步骤比较的繁杂,其实非常的简单,主要你明白了我们之前介绍的单点登陆——Session共享,那么对于上述的流程其实非常的好理解,说白了就是原来每个系统中都有一个登录页面,现在我们将所有系统的登录模块独立出来,做一个SSO认证中心。
下面我们就直接来看代码,结合代码来理解上述的CAS流程,首先看一下整体项目结构,相比较之前,这里多了一个登录模块,其他模块全部继承于 cas_support 模块,如下:
看到上述 cas_support 模块,发现东西还是几乎还是和原来差不多的,这里我们就来快速过一下代码,先看下用到的主要依赖,如下:
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
然后看下用户User类,其实和原来的内容及作用都是一样的,用于前端登录请求的传值,其中backUrl字段用于保存原请求路径(如未登录状态下访问系统1,被强制跳转至SSO认证中心,用户登录后需要跳转至用户原来想要访问的系统1),如下:
public class User implements Serializable{
private static final long serialVersionUID = -3266830420902121735L;
private String username;
private String password;
private String backUrl;
//省略相关Getter、Setter方法
}
其中 MySession 类就是继承了 HttpSession 实现的自己的 Session 类,继承了该接口需要实现很多方法,我们这里就主要实现了下面两个方法,其余的暂不实现,还是和之前一样,如下:
public class MySession implements HttpSession, Serializable {
private static final long serialVersionUID = 243457112279446716L;
private String id;
private Map<String, Object> attrs;
public void setId(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
public Map<String, Object> getAttrs() {
return attrs;
}
public void setAttrs(Map<String, Object> attrs) {
this.attrs = attrs;
}
@Override
public Object getAttribute(String s) {
return this.attrs.get(s);
}
@Override
public void setAttribute(String s, Object o) {
this.attrs.put(s, o);
}
//省略其余继承接口需要实现的方法
//...
}
有关 MySession 类的作用,以及其中的两个参数id、attr的作用,我们在单点登陆——Session共享都详细介绍过,可以先了解一下。
然后看一下 MyRequestWrapper 类,这个类就和之前介绍的有一定的变动了,其中我们新增了一个 sessionId 的参数字段,以及其 set 赋值方法
这个字段主要的作用,就是在上述MyRequestWrapper类获取MySession对象时,如果不存在的话,之前会直接尝试从Cookie获取携带的sessionId,然后判断是否存在,创建MySession对象。这里加入了sessionId字段,就是在从从Cookie获取携带的sessionId之前,加了一步判断,如下:
为什么会先获取该值?这个sessionId又是在什么地方进行赋值的呢?这个是因为我们在登录模块被独立出去提供服务了,在请求被重定向至登录模块进行登录,登录成功后,会生成了一个授权令牌ticket,并且登录模块会将该ticket和sessionId作为关系存入Redis中(为了安全起见,一般ticket存入Redis设置几秒后会过期),然后再将ticket作为重定向的参数返回给子系统。(下截图为登录模块cas_login部分的Controller代码,后续会介绍)
然后子系统拿到ticket就可以从Redis中获取sessionId,再通过sessionId就可以获取Redis中的已登录用户信息(登录模块和之前一样,Filter过滤器最后会将登录的用户信息存入Redis),子系统获取到当前登录信息后,就可以自己种Cookie了,这样访问其他页面就无需再次经过登录模块认证了。(下截图为cas_support模块Filter部分代码,后续会介绍)
其 MyRequestWrapper 类的完整代码,如下:
public class MyRequestWrapper extends HttpServletRequestWrapper {
private volatile boolean committed = false;
private String sessionId = null;
private MySession session;
private RedisTemplate redisTemplate;
public MyRequestWrapper(HttpServletRequest request, RedisTemplate redisTemplate) {
super(request);
this.redisTemplate = redisTemplate;
}
//是否已登陆
public boolean isLogin() {
Object user = getSession().getAttribute(CookieUtil.SESSION_USER_INFO);
return null != user;
}
//取session
public MySession getSession() {
if (null != session) {
return session;
}
return this.createSession();
}
//创建新session
public MySession createSession() {
String mySessionId = null != sessionId ? sessionId : CookieUtil.getRequestedSessionId(this);
Map<String, Object> attr;
if (!StringUtils.isEmpty(mySessionId)) {
attr = redisTemplate.opsForHash().entries(mySessionId);
} else {
mySessionId = UUID.randomUUID().toString();
attr = new HashMap<>();
}
//session成员变量持有
session = new MySession();
session.setId(mySessionId);
session.setAttrs(attr);
return session;
}
//提交session内值到Redis
public void commitSession() {
if (committed) {
return;
}
committed = true;
MySession session = this.getSession();
if (session != null && null != session.getAttrs()) {
redisTemplate.opsForHash().putAll(session.getId(), session.getAttrs());
}
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
}
上述 MyRequestWrapper 类中用到的CookieUtil工具类,用于获取Cookie中携带的sessionId,以及种Cookie,其内容和之前几乎一样,唯一区别就是我们在种Cookie的时候,将 .setDomain()
方法注释了,因为这里就不需要,如下:
public class CookieUtil {
public static final String SESSION_NAME = "mysession";
public static final String SESSION_USER_INFO = "user";
public static String getRequestedSessionId(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (!SESSION_NAME.equalsIgnoreCase(cookie.getName())) {
continue;
}
return cookie.getValue();
}
return null;
}
public static void onNewSession(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession();
String sessionId = session.getId();
Cookie cookie = new Cookie(SESSION_NAME, sessionId);
cookie.setHttpOnly(true);
//cookie.setDomain("bxs.com");
cookie.setPath(request.getContextPath() + "/");
cookie.setMaxAge(7 * 24 * 60 * 60);
response.addCookie(cookie);
}
}
最后我们看一下 cas_support 模块还剩下的 Filter 过滤器,之前我们在单点登陆——Session共享中,我们将Filter都是分别放在不同的子系统中的,因为其每个系统都有自己的登录页面,其跳转的url地址也是不同的。
但是这里我们将登录模块独立出来,做成了一个SSO认证中心,那么我们就将Filter过滤器也放入了cas_support模块中,因为这里跳转登录的地址现在是同一的了,如下:
//@WebFilter(filterName = "sessionFilter", urlPatterns = "/*")
public class SessionFilter implements Filter {
private RedisTemplate redisTemplate;
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//包装request对象
MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request, redisTemplate);
String requestUrl = request.getServletPath();
//如果未登陆状态,进入下面逻辑
if (!"/login".equals(requestUrl) && !"/toLogin".equals(requestUrl) && !myRequestWrapper.isLogin()) {
String ticket = request.getParameter("ticket");
//ticket为空,或无对应sessionid为空,表明不是自动登陆请求,则直接强制到登陆页面
if (null == ticket || null == redisTemplate.opsForValue().get(ticket)) {
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.sendRedirect("http://login.bxs.com:8080/login?backUrl=" + request.getRequestURL().toString());
return;
}
//是自动登陆请求,则种cookie值进去,目的是重定向后的下次请求,自带本cookie,将直接是登陆状态
myRequestWrapper.setSessionId((String) redisTemplate.opsForValue().get(ticket));
myRequestWrapper.createSession();
//种cookie
CookieUtil.onNewSession(myRequestWrapper, (HttpServletResponse) servletResponse);
//重定向自流转一次,原地跳转重向一次(给浏览器反馈刚刚种的cookie,避免中cookie后续失效)
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.sendRedirect(request.getRequestURL().toString());
return;
}
try {
filterChain.doFilter(myRequestWrapper, servletResponse);
} finally {
//提交session到Redis
myRequestWrapper.commitSession();
}
}
@Override
public void destroy() {
}
}
这里就无法直接通过cookie-session来判断是否登录了,这里我们就需要通过登录模块返回的ticket参数来判断(借助Redis),然后获取到登录信息后,在子系统中自己进行种Cookie,需要注意的是,这里我们种完Cookie后,需要立即自身重定向一次,主要目的就是为了给浏览器种Cookie,否则往下走,可能会导致种的Cookie丢失。
另外这里需要注意的是,所有Filter现在都是在cas_support模块中了,该Filter在我们子系统中肯定也是需要用到的,有关Filter注入项目中,我们在单点登陆——Session共享中介绍了两种方法,这里我们最好使用 @Bean
的形式手动注入,否则可能出现问题。
(注:在测试中,使用了@WebFilter
加@ServletComponentScan
注解来完成时,测试发现在重定向时,登录模块登录认证后,进行携带令牌ticket重定向时,中间总会夹杂 /favicon.ico
请求,导致后续处理空指针异常,按照网上的设置,总是禁止不了该请求,改为@Bean
测试成功,被坑好久…)
然后我们再来介绍下 cas_login 登录模块,如下:
首先我们就需要按上述介绍的,使用 @Bean
来添加过滤器了,如下:
@Configuration
public class SessionConfig {
@Bean
public SessionFilter sessionFilter(RedisTemplate redisTemplate){
SessionFilter sessionFilter = new SessionFilter();
sessionFilter.setRedisTemplate(redisTemplate);
return sessionFilter;
}
@Bean
public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(sessionFilter);
registration.addUrlPatterns("/*");
registration.setName("sessionFilter");
registration.setOrder(1); //值越小,Filter越靠前。
return registration;
}
}
那么在 cas_login 登录模块,因为这里又不用拦截请求跳转,那么我们不添加 Filter 过滤器可以么?当然不行的呀,因为我们的Filter过滤器不仅仅帮我们拦截非法请求,进行跳转至登录模块。而且在Filter中,还帮助我们把HttpServletRequest转换成了我们自己定义的MyRequestWrapper类。
接下来看一看 cas_login 模块的Controller方法,如下:
@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/login")
public String toLogin(Model model, MyRequestWrapper request) {
if (request.isLogin()) {
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket, request.getSession().getId(), 20, TimeUnit.SECONDS);
return "redirect:" + request.getParameter("backUrl") + "?ticket=" + ticket;
}
User user = new User();
user.setUserName("");
user.setPassWord("");
user.setBackUrl(request.getParameter("backUrl"));
model.addAttribute(CookieUtil.SESSION_USER_INFO, user);
return "login";
}
@PostMapping("/toLogin")
public void login(@ModelAttribute User user, MyRequestWrapper request, HttpServletResponse response) throws IOException {
request.getSession().setAttribute(CookieUtil.SESSION_USER_INFO, user);
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket, request.getSession().getId(), 20, TimeUnit.SECONDS);
CookieUtil.onNewSession(request, response);
response.sendRedirect(user.getBackUrl() + "?ticket=" + ticket);
}
}
其中 /login
方法,若用户未登录,则返回 login.html 页面;若用户已登录,则返回授权令牌ticket。而另一个方法/toLogin
是用户在登录页面输入账号密码进行登录,登录后直接携带授权令牌重定向返回。
其中登录页面和之前一致,如下:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>cas_login</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<div text-align="center">
<h1>请登陆</h1>
<form action="#" th:action="@{/toLogin}" th:object="${user}" method="post">
<p>用户名: <input type="text" th:field="*{userName}"/></p>
<p>密 码: <input type="text" th:field="*{passWord}"/></p>
<input type="text" th:field="*{backUrl}" hidden="hidden"/>
<p><input type="submit" value="submit"/></p>
</form>
</div>
</body>
</html>
最后就是一个yml配置文件了,这个和之前的也是差不多,这里我们配置的是8080端口,然后cas_a模块配置的是8081端口,cas_b模块配置的是8082端口,其余一致,如下:
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
然后至于 cas_a 及 cas_b 模块,其内容这里都是一样的了,用于模拟两个子系统,如下:
@Configuration
public class SessionConfig {
@Bean
public SessionFilter sessionFilter(RedisTemplate redisTemplate){
SessionFilter sessionFilter = new SessionFilter();
sessionFilter.setRedisTemplate(redisTemplate);
return sessionFilter;
}
@Bean
public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(sessionFilter);
registration.addUrlPatterns("/*");
registration.setName("sessionFilter");
registration.setOrder(1); //值越小,Filter越靠前。
return registration;
}
}
这个 SessionConfig 其实和 cas_login 模块中的也是一样的,就是为了配置Filter过滤器。
然后我们在看下cas_a和cas_b模块的Controller,其中就是用于登录后访问的页面,其实这里我们发现就是将其中的Controller中用于登录的部分拆分了出去,然后在子系统中就不用处理登录相关的业务了,如下:
@Controller
public class IndexController {
@GetMapping("/index")
public ModelAndView index(MyRequestWrapper request) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", request.getSession().getAttribute(CookieUtil.SESSION_USER_INFO));
return modelAndView;
}
}
最后的登陆成功的展示页面 index.html ,也是和之前的一样,内容如下:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>cas_b</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<div>
<h1>你好,<span th:text="${user.userName}"/></h1>
</div>
</body>
</html>
最后我们就来进行测试,在测试前,我们先去本地的hosts文件中将 127.0.0.1 指向不同的域名,如下:
然后我们还需要先启动Redis服务,然后再启动cas_login,cas_a,cas_b模块,分别访问 a、b 的 /index 路径,即 a.bxs1.com:8081/index ,b.bxs2.com:8082/index ,如下:
上述我们可以看到,两个系统的域名是完全不一致的,然后我们登陆其中一个,然后另一个直接刷新进行查看,结果如下:
上一篇: 防止口唇干燥 多吃五种食物
下一篇: 秋季T区油光再现,怎样去油有三步