查看原文
其他

单点登录实现(spring session+redis完成session共享)

作者:小眼

主页:www.cnblogs.com/hujunzheng


一、前言

项目中用到的SSO,使用开源框架cas做的。简单的了解了一下cas,并学习了一下 单点登录的原理,有兴趣的同学也可以学习一下,写个demo玩一玩。


二、工程结构

我模拟了 sso的客户端和sso的服务端, sso-core中主要是一些sso需要的过滤器和工具类,缓存和session共享的一些XML配置文件,还有springmvc需要的一下jar包的管理。sso-cache中配置了redis缓存策略。


三、单点登录原理图


简单描述:

  1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数

  2. sso认证中心发现用户未登录,将用户引导至登录页面

  3. 用户输入用户名密码提交登录申请

  4. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌

  5. sso认证中心带着令牌跳转会最初的请求地址(系统1)

  6. 系统1拿到令牌,去sso认证中心校验令牌是否有效

  7. sso认证中心校验令牌,返回有效,注册系统1

  8. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源

  9. 用户访问系统2的受保护资源

  10. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数

  11. sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌

  12. 系统2拿到令牌,去sso认证中心校验令牌是否有效

  13. sso认证中心校验令牌,返回有效,注册系统2

  14. 系统2使用该令牌创建与用户的局部会话,返回受保护资源


四、单点登录实现


1.SSOFilter.java(sso client filter实现)

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.alibaba.fastjson.JSONObject;
import com.hjz.sso.utils.RestTemplateUtil;

public class SSOFilter implements Filter{

  public static Logger logger = LoggerFactory.getLogger(SSOFilter.class);
 
  private String SSO_SERVER_URL;
  private String SSO_SERVER_VERIFY_URL;
 
  @Override
  public void init(FilterConfig filterConfig) throws ServletException {
      SSO_SERVER_URL = filterConfig.getInitParameter("SSO_SERVER_URL");
      SSO_SERVER_VERIFY_URL = filterConfig.getInitParameter("SSO_SERVER_VERIFY_URL");
      if(SSO_SERVER_URL == null) logger.error("SSO_SERVER_URL is null.");
      if(SSO_SERVER_VERIFY_URL == null) logger.error("SSO_SERVER_VERIFY_URL is null.");
  }
 
  @Override
  public void doFilter(ServletRequest req, ServletResponse res,
          FilterChain chain) throws IOException, ServletException
{
       HttpServletRequest request = (HttpServletRequest) req;
       HttpServletResponse response = (HttpServletResponse) res;
       //请求中带有token,去sso-server验证token是否有效
       String authority = null;
       if(request.getParameter("token") != null) {
          boolean verifyResult = this.verify(request, SSO_SERVER_VERIFY_URL, request.getParameter("token"));
          if (verifyResult) {
              chain.doFilter(req, res);
              return;
          } else {
              authority = "token->" + request.getParameter("token") + " is invalidate.";
          }
       }
       
       HttpSession session = request.getSession();
       if (session.getAttribute("login") != null && (boolean)session.getAttribute("login") == true) {
          chain.doFilter(req, res);
          return;
       }
       //跳转至sso认证中心
       String callbackURL = request.getRequestURL().toString();
       StringBuilder url = new StringBuilder();
       url.append(SSO_SERVER_URL).append("?callbackURL=").append(callbackURL);
       if(authority != null) {
           url.append("&authority=").append(authority);
       }
       response.sendRedirect(url.toString());
  }
 
  private boolean verify(HttpServletRequest request, String verifyUrl, String token) {
      String result = RestTemplateUtil.get(request, verifyUrl + "?token=" + token, null);
      JSONObject ret = JSONObject.parseObject(result);
      if("success".equals(ret.getString("code"))) {
          return true;
      }
      logger.error(request.getRequestURL().toString() + " : " + ret.getString("msg"));
      return false;
  }

  @Override
  public void destroy() {
  }

}


2.LoginController.java(sso server登录controller)

import java.util.UUID;

import javax.servlet.http.HttpSession;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequestMapping("sso")
public class LoginController {
   private Logger logger = LoggerFactory.getLogger(LoginController.class);
   
   @RequestMapping(value="login", method={RequestMethod.GET, RequestMethod.POST})
   public String login(HttpSession session, Model model,
           @RequestParam(value="name", required=false) String name,
           @RequestParam(value="password", required=false) String password) {
       if(name == null && password == null) return "login";
       if("admin".equals(name) && "admin".equals(password)) {
           String token = UUID.randomUUID().toString();
           session.setAttribute("login", true);
           session.setAttribute("token", token);
           return "index";
       } else {
           model.addAttribute("error", true);
           model.addAttribute("message", "用户名或密码错误。");
           return "login";
       }
   }
}


3.ValidateController.java(sso server验证token controller)

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import com.alibaba.fastjson.JSONObject;

@Controller
@RequestMapping("sso")
public class ValidateController {
   
   @RequestMapping("verify")
   @ResponseBody
   public JSONObject verify(HttpServletRequest request, @RequestParam String token) {
       HttpSession session = request.getSession();
       JSONObject result = new JSONObject();
       if(session.getAttribute("token") != null && token.equals(session.getAttribute("token"))) {
           result.put("code", "success");
           result.put("msg", "认证成功");
       } else {
           result.put("code", "failure");
           result.put("msg", "token已失效,请重新登录!");
       }
       return result;
   }
   
}


4.在sso client工程中加上SSOFilter(web.xml部分配置)

<filter>
   <filter-name>ssoFilter</filter-name>
   <filter-class>com.hjz.sso.filter.SSOFilter</filter-class>
   <init-param>
       <param-name>SSO_SERVER_URL</param-name>
       <param-value>http://localhost:8088/sso-server/sso/login</param-value>
   </init-param>
   <init-param>
       <param-name>SSO_SERVER_VERIFY_URL</param-name>
       <param-value>http://localhost:8088/sso-server/sso/verify</param-value>
   </init-param>
</filter>

<filter-mapping>
   <filter-name>ssoFilter</filter-name>
   <url-pattern>/*</url-pattern>
</filter-mapping>


基本模型已经出来了,启动sso-client 和 sso-server(本人都部署到了同一个tomcat下),试图去验证单点登录。测试的时候,从浏览器中的cookie中查看,可以看到 localhost域下有多个JSESSIONID。


这也难怪, Tomcat中的每一个application都会创建自己的session会话。那接下来的事情就是解决 session 共享的问题,这样我们就可以完成我们的单点登陆了。


为完成 session共享,这里推荐两种方案。一个是 tomcat+redis实现session共享,一个是 spring session+redis实现session共享。我这里采用了第二种方案,详情请接着看下面的步骤。


5.为每个工程的web.xml中增加spring session代理filter的配置

<!-- session 代理 -->
<filter>
   <filter-name>springSessionRepositoryFilter</filter-name>
   <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>

<filter-mapping>
   <filter-name>springSessionRepositoryFilter</filter-name>
   <url-pattern>/*</url-pattern>
</filter-mapping>


6.在sso-core中加入 缓存和spring session的xml配置(cache-config.xml)

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:context="http://www.springframework.org/schema/context"
      xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
       http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd"
      default-lazy-init="false">


   <description>Cache公共配置</description>
   <bean id="cookieSerializer" class="org.springframework.session.web.http.DefaultCookieSerializer">
       <property name="cookiePath" value="/"></property>
   </bean>
   
   <bean class="com.sso.cache.config.CacheConfig"/>
   
   <bean class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
       <property name="maxInactiveIntervalInSeconds" value="1800"></property>
   </bean>
</beans>


这里说一下为什么有定义一个 cookieSerializer 这个bean。参看RedisHttpSessionConfiguration的源码,发现它继承了SpringHttpSessionConfiguration,继续查看源码,发现SpringHttpSessionConfiguration中实现了我们配置的spring session代理filter,如下所示。

SpringHttpSessionConfiguration.java

@Bean
public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(
       SessionRepository<S> sessionRepository) {
   SessionRepositoryFilter sessionRepositoryFilter = new SessionRepositoryFilter(sessionRepository);

   sessionRepositoryFilter.setServletContext(this.servletContext);
   if (this.httpSessionStrategy instanceof MultiHttpSessionStrategy) {
       sessionRepositoryFilter.setHttpSessionStrategy((MultiHttpSessionStrategy) this.httpSessionStrategy);
   } else {
       sessionRepositoryFilter.setHttpSessionStrategy(this.httpSessionStrategy);
   }
   return sessionRepositoryFilter;
}


查看源码,可以发现 SpringHttpSessionConfiguration使用的默认会话策略(httpSessionStrategy)是CookieHttpSessionStrategy。继续查看CookieHttpSessionStrategy的源码,如新建session写入cookie。

public void onNewSession(Session session, HttpServletRequest request, HttpServletResponse response) {
   Set sessionIdsWritten = getSessionIdsWritten(request);
   if (sessionIdsWritten.contains(session.getId())) {
       return;
   }
   sessionIdsWritten.add(session.getId());

   Map sessionIds = getSessionIds(request);
   String sessionAlias = getCurrentSessionAlias(request);
   sessionIds.put(sessionAlias, session.getId());

   String cookieValue = createSessionCookieValue(sessionIds);
   this.cookieSerializer.writeCookieValue(new CookieSerializer.CookieValue(request, response, cookieValue));
}


cookieSerializer 默认是 DefaultCookieSerializer。查看DefaultCookieSerializer 的 writeCookieValue方法如下。

public void writeCookieValue(CookieSerializer.CookieValue cookieValue) {
   HttpServletRequest request = cookieValue.getRequest();
   HttpServletResponse response = cookieValue.getResponse();

   String requestedCookieValue = cookieValue.getCookieValue();
   String actualCookieValue = requestedCookieValue + this.jvmRoute;

   Cookie sessionCookie = new Cookie(this.cookieName, actualCookieValue);
   sessionCookie.setSecure(isSecureCookie(request));
   sessionCookie.setPath(getCookiePath(request));
   String domainName = getDomainName(request);
   if (domainName != null) {
       sessionCookie.setDomain(domainName);
   }

   if (this.useHttpOnlyCookie) {
       sessionCookie.setHttpOnly(true);
   }

   if ("".equals(requestedCookieValue)) {
       sessionCookie.setMaxAge(0);
   } else {
       sessionCookie.setMaxAge(this.cookieMaxAge);
   }
   response.addCookie(sessionCookie);
}


sessionCookie.setPath(getCookiePath(request));这块有一个问题,看一下getCookiePath方法的实现,如下。

private String getCookiePath(HttpServletRequest request) {
   if (this.cookiePath == null) {
       return request.getContextPath() + "/";
   }
   return this.cookiePath;
}


如果要实现单点登录,就不要使用默认的 cookiePath 的值。所以,我定义了一个 cookieSerializer 的bean,并指定了 cookiePath 的值。 SpringHttpSessionConfiguration中如下方法可以自动装配 我们配置的cookieSerializer,而不是使用默认的。

@Autowired(required = false)
public void setCookieSerializer(CookieSerializer cookieSerializer) {
   this.defaultHttpSessionStrategy.setCookieSerializer(cookieSerializer);
}


7.在每个工程中的spring公共配置文件中增加如下配置。

<import resource="classpath*:cache-config.xml"/>


8.后端之间rest请求传递 session ID。

private static ResponseEntity<String> request(ServletRequest req, String url, HttpMethod method, Map<String, ?> params) {
   HttpServletRequest request = (HttpServletRequest) req;
   //获取header信息
   HttpHeaders requestHeaders = new HttpHeaders();
   Enumeration<String> headerNames = request.getHeaderNames();
   while (headerNames.hasMoreElements()) {
     String key = (String) headerNames.nextElement();
     String value = request.getHeader(key);
     requestHeaders.add(key, value);
   }
   HttpEntity<String> requestEntity = new HttpEntity<String>(params != null ? JSONObject.toJSONString(params) : null, requestHeaders);
   ResponseEntity<String> rss = restTemplate.exchange(url, method, requestEntity, String.class);
   return rss;
}

使用RestTemplate发送rest请求,发送之前复制request中的header信息,保证session ID可以传递。


9.最后,启动工程,测试结果如下。


  • http://study.hujunzheng.cn:8000/sso-client-user/ http://study.hujunzheng.cn:8000/sso-client-org/ 

两个链接来切换访问工程。


本文涉及代码点击阅读原文获取。


关注后端技术精选,提供优质价值阅读

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存