spring security集成cas實現單點登錄

  • 2019 年 10 月 3 日
  • 筆記

spring security集成cas

0.配置本地ssl連接

操作記錄如下:

=====================1.創建證書文件thekeystore ,並導出為thekeystore.crt  cd C:Users23570keystore    C:Users23570keystore>keytool -genkey -keyalg RSA -alias thekeystore -keystore thekeystore  輸入密鑰庫口令:changeit  再次輸入新口令:changeit  您的名字與姓氏是什麼?    [Unknown]:  localhost  您的組織單位名稱是什麼?    [Unknown]:  localhost  您的組織名稱是什麼?    [Unknown]:  您所在的城市或區域名稱是什麼?    [Unknown]:  您所在的省/市/自治區名稱是什麼?    [Unknown]:  該單位的雙字母國家/地區代碼是什麼?    [Unknown]:  CN=localhost, OU=localhost, O=Unknown, L=Unknown, ST=Unknown, C=Unknown是否正確?    [否]:  y    輸入 <thekeystore> 的密鑰口令          (如果和密鑰庫口令相同, 按回車):    Warning:  JKS 密鑰庫使用專用格式。建議使用 "keytool -importkeystore -srckeystore thekeystore -destkeystore thekeystore -deststoretype pkcs12" 遷移到行業標準格式 PKCS12。    C:Users23570keystore>keytool -export -alias thekeystore -file thekeystore.crt -keystore thekeystore  輸入密鑰庫口令:  存儲在文件 <thekeystore.crt> 中的證書    Warning:  JKS 密鑰庫使用專用格式。建議使用 "keytool -importkeystore -srckeystore thekeystore -destkeystore thekeystore -deststoretype pkcs12" 遷移到行業標準格式 PKCS12。      ======================2.把證書文件導入到本地證書庫中,注意切換JRE相應目錄  切換為【管理員身份】運行以下命令:    C:Users23570keystore>keytool -import -alias thekeystore -storepass changeit -file thekeystore.crt -keystore "C:Program FilesJavajdk1.8.0_191jrelibsecuritycacerts"  所有者: CN=localhost, OU=localhost, O=Unknown, L=Unknown, ST=Unknown, C=Unknown  發佈者: CN=localhost, OU=localhost, O=Unknown, L=Unknown, ST=Unknown, C=Unknown  序列號: 657eb9ce  有效期為 Fri Mar 29 11:50:08 CST 2019 至 Thu Jun 27 11:50:08 CST 2019  證書指紋:           MD5:  8D:3C:78:E9:8A:44:77:3F:C2:8B:20:95:C7:6C:91:8F           SHA1: 69:F3:46:C4:03:95:E1:D0:E6:9D:8B:72:F4:EB:ED:13:8B:9A:6A:38           SHA256: 79:D1:F8:B2:1B:E3:AF:D4:4F:35:CB:6B:C8:84:3F:85:21:13:0F:96:4A:B5:E5:4C:47:11:44:21:8F:F3:2D:83  簽名算法名稱: SHA256withRSA  主體公共密鑰算法: 2048 位 RSA 密鑰  版本: 3    擴展:    #1: ObjectId: 2.5.29.14 Criticality=false  SubjectKeyIdentifier [  KeyIdentifier [  0000: B0 38 1D 00 56 65 EE 98   7C 35 58 04 B5 2E C0 A0  .8..Ve...5X.....  0010: D5 C2 C5 B5                                        ....  ]  ]    是否信任此證書? [否]:  y  證書已添加到密鑰庫中    =========================3.配置tomcat/conf/server.xml中的ssl連接    <Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"             maxThreads="200" SSLEnabled="true" scheme="https"             secure="true" clientAuth="false" sslProtocol="TLS"             keystoreFile="C:Users23570keystorethekeystore"             keystorePass="changeit"/>    ==========================4.其他命令參考  刪除JRE中指定別名的證書  keytool -delete -alias cas.server.com -keystore "C:Program FilesJavajdk1.8.0_191jrelibsecuritycacerts"    查看JRE中指定別名的證書  keytool -list -v -keystore "C:Program FilesJavajdk1.8.0_191jrelibsecuritycacerts" -alias cas.server.com  

1.cas服務搭建

git clone --branch 5.3 https://github.com/apereo/cas-overlay-template.git cas-server

注意:

這裡選用cas server 5.3版本,使用maven構建

1.使用數據庫賬號密碼登錄cas

導入依賴

<dependency>      <groupId>org.apereo.cas</groupId>      <artifactId>cas-server-support-jdbc</artifactId>      <version>${cas.version}</version>  </dependency>    <dependency>      <groupId>mysql</groupId>      <artifactId>mysql-connector-java</artifactId>      <version>5.1.47</version>  </dependency>

配置查詢

#這裡是配置用戶表單登錄時用戶名字段為username  cas.authn.jdbc.query[0].sql=select password from oauth_account left join oauth_user on oauth_account.user_id=oauth_user.user_id where oauth_user.username=?;  cas.authn.jdbc.query[0].fieldPassword=password  cas.authn.jdbc.query[0].fieldExpired=expired  cas.authn.jdbc.query[0].fieldDisabled=disabled    cas.authn.jdbc.query[0].dialect=org.hibernate.dialect.MySQLDialect  cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver  cas.authn.jdbc.query[0].url=jdbc:mysql://127.0.0.1:3306/srm-aurora2?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false  cas.authn.jdbc.query[0].user=root  cas.authn.jdbc.query[0].password=root    #默認不加密  #cas.authn.jdbc.query[0].passwordEncoder.type=NONE    #默認加密策略,通過encodingAlgorithm來指定算法,默認NONE不加密  cas.authn.jdbc.query[0].passwordEncoder.type=DEFAULT  cas.authn.jdbc.query[0].passwordEncoder.characterEncoding=UTF-8  cas.authn.jdbc.query[0].passwordEncoder.encodingAlgorithm=MD5    #配置用戶表單登錄時用戶名字段為phone  cas.authn.jdbc.query[1].sql=select password from oauth_account left join oauth_user on oauth_account.user_id=oauth_user.user_id where oauth_user.phone=?;  cas.authn.jdbc.query[1].fieldPassword=password  cas.authn.jdbc.query[1].fieldExpired=expired  cas.authn.jdbc.query[1].fieldDisabled=disabled    cas.authn.jdbc.query[1].dialect=org.hibernate.dialect.MySQLDialect  cas.authn.jdbc.query[1].driverClass=com.mysql.jdbc.Driver  cas.authn.jdbc.query[1].url=jdbc:mysql://127.0.0.1:3306/srm-aurora2?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false  cas.authn.jdbc.query[1].user=root  cas.authn.jdbc.query[1].password=root    #默認不加密  #cas.authn.jdbc.query[0].passwordEncoder.type=NONE    #默認加密策略,通過encodingAlgorithm來指定算法,默認NONE不加密  cas.authn.jdbc.query[1].passwordEncoder.type=DEFAULT  cas.authn.jdbc.query[1].passwordEncoder.characterEncoding=UTF-8  cas.authn.jdbc.query[1].passwordEncoder.encodingAlgorithm=MD5

數據庫腳本

/*   Navicat Premium Data Transfer     Source Server         : localhost   Source Server Type    : MySQL   Source Server Version : 50722   Source Host           : localhost:3306   Source Schema         : srm-aurora2     Target Server Type    : MySQL   Target Server Version : 50722   File Encoding         : 65001     Date: 19/04/2019 14:40:52  */    SET NAMES utf8mb4;  SET FOREIGN_KEY_CHECKS = 0;    -- ----------------------------  -- Table structure for oauth_account  -- ----------------------------  DROP TABLE IF EXISTS `oauth_account`;  CREATE TABLE `oauth_account`  (    `account_id` int(255) NOT NULL AUTO_INCREMENT,    `tenant_id` int(255) NULL DEFAULT NULL,    `user_id` int(255) NULL DEFAULT NULL,    `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    PRIMARY KEY (`account_id`) USING BTREE  ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;    -- ----------------------------  -- Records of oauth_account  -- ----------------------------  INSERT INTO `oauth_account` VALUES (1, 1, 1, 'e10adc3949ba59abbe56e057f20f883e');  INSERT INTO `oauth_account` VALUES (2, 2, 2, 'e10adc3949ba59abbe56e057f20f883e');    -- ----------------------------  -- Table structure for oauth_cas_info  -- ----------------------------  DROP TABLE IF EXISTS `oauth_cas_info`;  CREATE TABLE `oauth_cas_info`  (    `cas_id` int(255) NOT NULL,    `tenant_id` int(255) NULL DEFAULT NULL,    `cas_server` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `cas_server_login` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `cas_server_logout` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `cas_service` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `cas_service_logout` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    PRIMARY KEY (`cas_id`) USING BTREE  ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;    -- ----------------------------  -- Records of oauth_cas_info  -- ----------------------------  INSERT INTO `oauth_cas_info` VALUES (1, 2, 'https://localhost:8443/cas', 'https://localhost:8443/cas/login?service=http%3A%2F%2Flocalhost%3A8083%2Flogin%2Fcas', 'https://localhost:8443/cas/logout', 'http://localhost:8083/login/cas', 'https://localhost:8443/cas/logout?service=http://localhost:8083/logout/success');  INSERT INTO `oauth_cas_info` VALUES (2, 3, 'https://localhost:9443/sso', 'https://localhost:9443/sso/login?service=http%3A%2F%2Flocalhost%3A8083%2Flogin%2Fcas', 'https://localhost:9443/sso/logout', 'http://localhost:8083/login/cas', 'https://localhost:9443/sso/logout?service=http://localhost:8083/logout/success');    -- ----------------------------  -- Table structure for oauth_tenant  -- ----------------------------  DROP TABLE IF EXISTS `oauth_tenant`;  CREATE TABLE `oauth_tenant`  (    `tenant_id` int(255) NOT NULL AUTO_INCREMENT,    `domain` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,    `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `login_provider` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `login_type` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    PRIMARY KEY (`tenant_id`) USING BTREE  ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;    -- ----------------------------  -- Records of oauth_tenant  -- ----------------------------  INSERT INTO `oauth_tenant` VALUES (1, 'http://localhost:8084/', 'a租戶', 'oauth', 'form');  INSERT INTO `oauth_tenant` VALUES (2, 'http://localhost:8085/', 'b租戶', 'cas', 'wechat');  INSERT INTO `oauth_tenant` VALUES (3, 'http://localhost:8086/', 'c租戶', 'cas', 'form');    -- ----------------------------  -- Table structure for oauth_user  -- ----------------------------  DROP TABLE IF EXISTS `oauth_user`;  CREATE TABLE `oauth_user`  (    `user_id` int(255) NOT NULL AUTO_INCREMENT,    `username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    `email` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,    PRIMARY KEY (`user_id`) USING BTREE  ) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;    -- ----------------------------  -- Records of oauth_user  -- ----------------------------  INSERT INTO `oauth_user` VALUES (1, '22304', '15797656200', '[email protected]');  INSERT INTO `oauth_user` VALUES (2, 'admin', '15797656201', '[email protected]');    SET FOREIGN_KEY_CHECKS = 1;  

發佈cas server,訪問:

https://localhost:8443/cas/login

測試賬號和密碼,admin:123456

2.CAS客戶端服務註冊

這裡演示通過json文件註冊服務,實際項目中,可以配置成從數據庫中註冊

  1. 添加json支持依賴

    <!--json服務註冊-->  <dependency>      <groupId>org.apereo.cas</groupId>      <artifactId>cas-server-support-json-service-registry</artifactId>      <version>${cas.version}</version>  </dependency>
  2. 添加json服務註冊文件

    {    "@class" : "org.apereo.cas.services.RegexRegisteredService",    "serviceId" : "^(https|http|imaps)://.*",    "name" : "HTTPS and HTTP and IMAPS",    "id" : 10000001,    "description" : "This service definition authorizes all application urls that support HTTPS and HTTP and IMAPS protocols.",    "evaluationOrder" : 10000,    "attributeReleasePolicy": {      "@class": "org.apereo.cas.services.ReturnAllAttributeReleasePolicy"    },    "proxyPolicy": {      "@class": "org.apereo.cas.services.RegexMatchingRegisteredServiceProxyPolicy",      "pattern": "^(https|http)?://.*"    }  }

    注意文件目錄和文件名格式:

    目錄:resources/services/{xxx}-{id}.json

    xxx表示可以隨意配置,後面-{id},這裡的id需要和文件中的id一致。

    作為演示,這個json註冊文件,沒有限制域名,也就是說所有的服務都可以註冊成功。

  3. 開啟json服務註冊

      ##  # 開啟json服務註冊  #  cas.serviceRegistry.initFromJson=true

以上就是配置json服務註冊的過程。

3.其它常用配置

  ##  # 登出後允許跳轉到指定頁面  #  cas.logout.followServiceRedirects=true    # 設置service ticket的行為  # cas.ticket.st.maxLength=20  # cas.ticket.st.numberOfUses=1  cas.ticket.st.timeToKillInSeconds=120    # 設置proxy ticket的行為  cas.ticket.pt.timeToKillInSeconds=120  # cas.ticket.pt.numberOfUses=1

配置說明:

  1. 配置cas服務登出時,是否跳轉到各個子服務的登出頁面,默認false【即默認情況下,子服務點擊登出,用戶統一跳轉到cas的登出頁面】,子服務登出時訪問cas登出端點,並帶上service。

    示例:https://localhost:8443/cas/logout?service=http://localhost:8083/logout/success

    這樣配置,cas註銷session之後,會重定向到service。

    這個字段可以配置,默認是service。配置如下:

    cas.logout.redirectParameter=service
  2. 配置service ticket的失效時間,我這裡配置這個選項,是為了方便後面debug調試,實際生產中,不必配置這個選項。

更多常用配置項,請查看官網鏈接:https://apereo.github.io/cas/5.3.x/installation/Configuration-Properties.html

2.spring security和cas集成

1.依賴和其他配置

  1. 核心依賴

    <!--security-cas集成-->  <dependency>      <groupId>org.springframework.security</groupId>      <artifactId>spring-security-cas</artifactId>  </dependency>    <dependency>      <groupId>org.springframework.boot</groupId>      <artifactId>spring-boot-starter-security</artifactId>  </dependency>
  2. application.yml配置

    # 我這裡是為了方便調試  logging.level.org.springframework.security: debug  logging.level.web: debug

2.配置登錄端點

  1. spring security開啟表單登陸

    @Override      protected void configure(HttpSecurity http) throws Exception {       http.formLogin().loginPage("/login");   }

    這個配置,會開啟用戶表單登錄,並且配置登錄端點為/login

  2. 配置登錄端點響應邏輯

    @Controller  public class LoginEndpointConfig {        @Autowired      private TenantService tenantService;        @Autowired      private CasInfoService casInfoService;        @GetMapping("/login")      public String loginJump(HttpSession session) {          final String SAVED_REQUEST = "SPRING_SECURITY_SAVED_REQUEST";          Object attribute = session.getAttribute(SAVED_REQUEST);          if (attribute == null) {              //默認跳轉到登陸頁面              return "login";          }          if (attribute instanceof DefaultSavedRequest) {              DefaultSavedRequest savedRequest = (DefaultSavedRequest) attribute;              List<String> referer = savedRequest.getHeaderValues("referer");              if (referer.size() == 1) {                  //有referer請求頭                  String domain = referer.get(0);                  Tenant tenant = tenantService.selectByDomain(domain);                  if (tenant == null) {                      return "login";                  } else {                      String loginProvider = tenant.getLoginProvider();                      switch (loginProvider) {                          case "cas":                              //獲取cas地址                              CasInfo casInfoByTenantId = casInfoService.getCasInfoByTenantId(tenant.getTenantId());                              String casServerLogin = casInfoByTenantId.getCasServerLogin();                              session.setAttribute("casInfoByTenantId",casInfoByTenantId);                              return "redirect:" + casServerLogin;                          case "oauth":                              return "login";                          default:                              return "login";                        }                  }                } else {                  return "login";              }          }          return "login";      }  }

    我這裡的登陸邏輯實現了:用戶從第三方網站【平台的租戶】跳轉到這個網站時,根據跳轉過來的請求頭【referer】獲取這個租戶的域名,再從數據庫中查找這個域名對應的租戶信息和登錄邏輯。

    這裡的租戶信息有一個關鍵字段是:loginProvider,有兩種情況casoauth

    1. cas:租戶有自己的cas單點登錄系統,平台需要和租戶的cas集成
    2. oauth:租戶沒有cas,使用平台統一的表單登陸

    具體的登錄流程分析,在最後詳細介紹,這裡不過多講解。

3.配置CAS的ticket校驗以及登錄響應

  1. 自定義AuthenticationFilter

    因為我的需求是,每個租戶有自己的cas系統,所以每個cas地址不一樣,不可能使用官方的CasAuthenticationFilter 。具體原因是,官方的CasAuthenticationFilter在應用程序啟動時,資源匹配器就已經初始化好了,它只會對特定的cas地址發送ticket校驗請求。而要做到可配置,就只能自己實現這個邏輯,並且可配置的對相應cas server地址發出ticket校驗請求。

      public class CustomCasAuthenticationFilter extends AbstractAuthenticationProcessingFilter {      private final static String endpoint = "/login/cas";        private UserDetailsService userDetailsService;        public CustomCasAuthenticationFilter(String defaultFilterProcessesUrl, UserDetailsService userDetailsService) {          super(defaultFilterProcessesUrl);          this.userDetailsService = userDetailsService;      }        private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();      private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();        public CustomCasAuthenticationFilter() {          super(new AntPathRequestMatcher(endpoint));      }        @Override      public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {          HttpServletRequest req = (HttpServletRequest) request;          HttpServletResponse res = (HttpServletResponse) response;          if (!requiresAuthentication(req, res)) {              chain.doFilter(request, response);              return;          }          String ticket = obtainArtifact(req);          //開始校驗ticket          try {              CasInfo casInfo = (CasInfo) req.getSession().getAttribute("casInfoByTenantId");              if (StringUtils.hasText(casInfo.getCasServer())) {                  //獲取當前項目地址                  String service;                  int port = request.getServerPort();                  if (port != 80) {                      service = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + endpoint;                  } else {                      service = request.getScheme() + "://" + request.getServerName() + endpoint;                  }                  //開始校驗ticket                  Assertion validateResult = getTicketValidator(casInfo.getCasServer()).validate(ticket, service);                  //根據校驗結果,獲取用戶詳細信息                  UserDetails userDetails = null;                  try {                      userDetails = userDetailsService.loadUserByUsername(validateResult.getPrincipal().getName());                      if (this.logger.isDebugEnabled()) {                          logger.debug("userDetailsServiceImpl is loading username:"+validateResult.getPrincipal().getName());                      }                  } catch (UsernameNotFoundException e) {                      unsuccessfulAuthentication(req, res, e);                  }                  //手動封裝authentication對象                  assert userDetails != null;                  UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(validateResult.getPrincipal(), ticket, userDetails.getAuthorities());                  authentication.setDetails(userDetails);                  successfulAuthentication(req,res,chain,authentication);                  } else {                  unsuccessfulAuthentication(req, res, new BadCredentialsException("bad credential:ticket校驗失敗"));              }          } catch (TicketValidationException e) {              //ticket校驗失敗              unsuccessfulAuthentication(req, res, new BadCredentialsException(e.getMessage()));          }  //        chain.doFilter(request, response);      }        /**       * 不做任何操作,實際用戶認證在doFilter方法內完成,可以在此方法中對session進行自定義操作       */      public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {          return null;      }        /**       * 從HttpServletRequest請求中獲取ticket       */      private String obtainArtifact(HttpServletRequest request) {          String artifactParameter = "ticket";          return request.getParameter(artifactParameter);      }        /**       * 獲取Cas30ServiceTicketValidator,暫時沒有實現代理憑據       */      private TicketValidator getTicketValidator(String casServerUrlPrefix) {          return new Cas30ServiceTicketValidator(casServerUrlPrefix);      }        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {          if (this.logger.isDebugEnabled()) {              this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);          }            SecurityContextHolder.getContext().setAuthentication(authResult);          if (this.eventPublisher != null) {              this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));          }            this.successHandler.onAuthenticationSuccess(request, response, authResult);      }        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {          SecurityContextHolder.clearContext();          if (this.logger.isDebugEnabled()) {              this.logger.debug("Authentication request failed: " + failed.toString(), failed);              this.logger.debug("Updated SecurityContextHolder to contain null Authentication");              this.logger.debug("Delegating to authentication failure handler " + this.failureHandler);          }            this.failureHandler.onAuthenticationFailure(request, response, failed);      }    }  
  2. 把自定義的CustomCasAuthenticationFilter添加到spring security的過濾器鏈中

    @Qualifier("userDetailsServiceImpl")  @Autowired  private UserDetailsService userDetailsService;    private final static String endpoint = "/login/cas";    @Override  protected void configure(HttpSecurity http) throws Exception {   http.addFilterAt(new CustomCasAuthenticationFilter(endpoint, userDetailsService), UsernamePasswordAuthenticationFilter.class);  }

4.配置單點登出

  1. 自定義實現LogoutFilter

      public class CustomLogoutFilter extends GenericFilterBean {      private RequestMatcher logoutRequestMatcher;      private SimpleUrlLogoutSuccessHandler urlLogoutSuccessHandler;      private LogoutHandler logoutHandler = new SecurityContextLogoutHandler();        //獲取casInfo信息,依此來判斷當前認證用戶的cas地址      private CasInfoService casInfoService;        public CustomLogoutFilter(String filterProcessesUrl, String logoutSuccessUrl,CasInfoService casInfoService) {          this.logoutRequestMatcher = new AntPathRequestMatcher(filterProcessesUrl);          this.urlLogoutSuccessHandler=new SimpleUrlLogoutSuccessHandler();          this.urlLogoutSuccessHandler.setDefaultTargetUrl(logoutSuccessUrl);          this.casInfoService = casInfoService;      }        @Override      public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {          HttpServletRequest request = (HttpServletRequest) servletRequest;          HttpServletResponse response = (HttpServletResponse) servletResponse;            if (requiresLogout(request, response)) {              Authentication auth = SecurityContextHolder.getContext().getAuthentication();                if (logger.isDebugEnabled()) {                  logger.debug("Logging out user '" + auth                          + "' and transferring to logout destination");              }              //本地登出              logoutHandler.logout(request,response,auth);              if (auth == null) {                  urlLogoutSuccessHandler.onLogoutSuccess(request,response, null);              }else{                  //判斷是否通過cas認證,獲取cas信息                  Object details = auth.getDetails();                  if (details == null) {                      urlLogoutSuccessHandler.onLogoutSuccess(request,response,auth);                  }                  if (details instanceof UserDetails) {                      Integer tenantId = ((UserDetailsVO) details).getTenant().getTenantId();                      CasInfo casInfoByTenantId = casInfoService.getCasInfoByTenantId(tenantId);                      response.sendRedirect(casInfoByTenantId.getCasServiceLogout());                  }else{                      urlLogoutSuccessHandler.onLogoutSuccess(request,response,auth);                  }              }              return;          }            filterChain.doFilter(request, response);        }        /**       * 當前請求是否為登出請求       */      private boolean requiresLogout(HttpServletRequest request,                                       HttpServletResponse response) {          return logoutRequestMatcher.matches(request);      }    }  
  2. CustomLogoutFilter添加到spring security的過濾器鏈中

    @Override  protected void configure(HttpSecurity http) throws Exception {   http.addFilterAt(new CustomLogoutFilter("/logout", "/logout/success", casInfoService), LogoutFilter.class);  }

5.流程分析

1.表單登陸流程分析

目前有5個服務

cas server,tenant-a,tenant-b,tenant-c,a2-oauth

租戶a,b,c就是一個超鏈接而已,為了模擬三個租戶的域名,所以弄了三個租戶。

這三個域名分別是:

<http://localhost:8084/> , <http://localhost:8085/> , <http://localhost:8086/>

數據庫中,對這3個租戶的配置如下:

其中b和c租戶是配置了cas登錄的。

cas server發佈了兩個,都開了SSL鏈接,分別是:

https://localhost:8443/cas ,https://localhost:9443/sso

我們先測試表單登錄。啟動租戶a,訪問鏈接http://localhost:8084 ,這個頁面只有一個超鏈接,點擊超鏈接,訪問

http://localhost:8083/oauth/authorize?client_id=youku&response_type=token&redirect_uri=http://localhost:8081/youku/qq/redirect

查看日誌:

//前面經過spring security的一堆過濾器鏈,都沒有匹配到  FrameworkEndpointHandlerMapping : Mapped to public org.springframework.web.servlet.ModelAndView org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.authorize(java.util.Map<java.lang.String, java.lang.Object>,java.util.Map<java.lang.String, java.lang.String>,org.springframework.web.bind.support.SessionStatus,java.security.Principal)    //用戶未認證,無法授權,拋出異常,ExceptionTranslationFilter對異常處理,跳轉到配置的authentication //entry point,這裡的authentication entry point,就是我之前配置的/login端點  2019-04-19 16:01:14.608 DEBUG 21568 --- [nio-8083-exec-1] o.s.web.servlet.DispatcherServlet        : Failed to complete request: org.springframework.security.authentication.InsufficientAuthenticationException: User must be authenticated with Spring Security before authorization can be completed.  2019-04-19 16:01:14.611 DEBUG 21568 --- [nio-8083-exec-1] o.s.s.w.a.ExceptionTranslationFilter     : Authentication exception occurred; redirecting to authentication entry point    org.springframework.security.authentication.InsufficientAuthenticationException: User must be authenticated with Spring Security before authorization can be completed.

可以看到,已經進入到了controller裏面。

final String SAVED_REQUEST = "SPRING_SECURITY_SAVED_REQUEST";          Object attribute = session.getAttribute(SAVED_REQUEST);

這段代碼的作用是為了拿到,之前發起的請求。那麼這個請求是什麼時候被保存的呢?

我們知道拋出異常之後,ExceptionTranslationFilter對異常進行處理,檢測到用戶沒有登錄,所以才跳轉到authentication entry point,所以,猜想應該是這裡保存了最開始的請求信息。

以下是ExceptionTranslationFilter的核心代碼:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {      this.handleSpringSecurityException(request, response, chain, (RuntimeException)ase);  }    private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {          if (exception instanceof AuthenticationException) {              this.logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception);              this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);          } else if (exception instanceof AccessDeniedException) {              Authentication authentication = SecurityContextHolder.getContext().getAuthentication();              if (!this.authenticationTrustResolver.isAnonymous(authentication) && !this.authenticationTrustResolver.isRememberMe(authentication)) {                  this.logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception);                  this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);              } else {                  this.logger.debug("Access is denied (user is " + (this.authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception);                  this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));              }          }        }

這裡對異常的處理,其實,核心就只有兩個方法:

  1. this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception); ,這種情況下,用戶已經登陸了,但是權限不夠,所以交給accessDeniedHandler進行處理,一般來講,如果沒有進行特殊的配置,會返回一個403錯誤和異常信息【不再跳轉到authentication entry point,因為用戶已經登陸了】,這裡不深究。

  2. this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception); ,這個方法核心代碼如下:

    protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {          SecurityContextHolder.getContext().setAuthentication((Authentication)null);       //就是在這裡保存的這次請求的所有信息,包括請求頭,請求路徑,參數,cookie等詳細信息。所以,後面跳轉到/login端點時,我在controller裏面可以拿出來。          this.requestCache.saveRequest(request, response);          this.logger.debug("Calling Authentication entry point.");       //這裡就是發起用戶認證了,根據我的配置,它就會跳轉到/login          this.authenticationEntryPoint.commence(request, response, reason);      }

再回到前面的controller登錄邏輯,往下走:

@GetMapping("/login")  public String loginJump(HttpSession session) {      final String SAVED_REQUEST = "SPRING_SECURITY_SAVED_REQUEST";      Object attribute = session.getAttribute(SAVED_REQUEST);      // 默認情況下,用戶直接訪問/login時,沒有SAVED_REQUEST      if (attribute == null) {          //默認跳轉到登陸頁面          return "login";      }      if (attribute instanceof DefaultSavedRequest) {          DefaultSavedRequest savedRequest = (DefaultSavedRequest) attribute;          List<String> referer = savedRequest.getHeaderValues("referer");          if (referer.size() == 1) {              //有referer請求頭              String domain = referer.get(0);              //獲取到數據庫中配置的租戶信息              Tenant tenant = tenantService.selectByDomain(domain);              if (tenant == null) {                  return "login";              } else {                  String loginProvider = tenant.getLoginProvider();                  switch (loginProvider) {                      case "cas":                          //獲取cas地址                          CasInfo casInfoByTenantId = casInfoService.getCasInfoByTenantId(tenant.getTenantId());                          String casServerLogin = casInfoByTenantId.getCasServerLogin();                          session.setAttribute("casInfoByTenantId",casInfoByTenantId);                          return "redirect:" + casServerLogin;                      case "oauth":                          //因為我在數據庫中配置的是oauth,所以,最後響應login視圖                          return "login";                      default:                          return "login";                    }              }            } else {              return "login";          }      }      return "login";  }

用戶跳轉到登陸頁面

輸入用戶名密碼,點擊登陸,進入UsernamePasswordAuthenticationFilter ,開始嘗試認證用戶

public Authentication attemptAuthentication(HttpServletRequest request,              HttpServletResponse response) throws AuthenticationException {          if (postOnly && !request.getMethod().equals("POST")) {              throw new AuthenticationServiceException(                      "Authentication method not supported: " + request.getMethod());          }            String username = obtainUsername(request);          String password = obtainPassword(request);            if (username == null) {              username = "";          }            if (password == null) {              password = "";          }            username = username.trim();            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(                  username, password);            // Allow subclasses to set the "details" property          setDetails(request, authRequest);            return this.getAuthenticationManager().authenticate(authRequest);      }

最終會調用AuthenticationManager接口的authenticate方法,而AuthenticationManager委託一堆的AuthenticationProvider來進行認證。後面的流程,不再贅述,不在本篇文章的討論範疇。

用戶認證成功後,調用successfulAuthentication(request, response, chain, authResult); 其實,這個方法裏面核心代碼就是successHandler.onAuthenticationSuccess(request, response, authResult);

AuthenticationSuccessHandler有很多實現類,我們也可以自定義實現AuthenticationSuccessHandler。最常用的實現是,SavedRequestAwareAuthenticationSuccessHandler ,看一下它裏面的核心代碼:

@Override      public void onAuthenticationSuccess(HttpServletRequest request,              HttpServletResponse response, Authentication authentication)              throws ServletException, IOException {          SavedRequest savedRequest = requestCache.getRequest(request, response);            if (savedRequest == null) {              super.onAuthenticationSuccess(request, response, authentication);                return;          }          String targetUrlParameter = getTargetUrlParameter();          if (isAlwaysUseDefaultTargetUrl()                  || (targetUrlParameter != null && StringUtils.hasText(request                          .getParameter(targetUrlParameter)))) {              requestCache.removeRequest(request, response);              super.onAuthenticationSuccess(request, response, authentication);                return;          }            clearAuthenticationAttributes(request);            // Use the DefaultSavedRequest URL          String targetUrl = savedRequest.getRedirectUrl();          logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);          getRedirectStrategy().sendRedirect(request, response, targetUrl);      }

其實,這個方法,就是獲取到之前保存的請求信息,然後再重定向到之前的請求。

2.CAS登錄流程分析

這次,我們訪問租戶b,這個租戶,配置了cas登錄。

訪問租戶b:http://localhost:8085/ ,這個頁面里,也就是一個超鏈接,點擊超鏈接,訪問

http://localhost:8083/oauth/authorize?client_id=iqiyi&response_type=token&redirect_uri=http://localhost:8081/iqiyi/qq/redirect

前面的流程還是一樣的,經過spring security的過濾器鏈,都沒有匹配到,在最後DispatcherServlet拋出異常,然後ExceptionTranslationFilter對異常處理,跳轉到/login端點,然後拿出配置在數據庫中的casInfo,跳轉到

https://localhost:8443/cas/login?service=http%3A%2F%2Flocalhost%3A8083%2Flogin%2Fcas

輸入用戶名密碼,cas成功認證用戶之後,生成TGT

=============================================================  WHO: admin  WHAT: Supplied credentials: [admin]  ACTION: AUTHENTICATION_SUCCESS  APPLICATION: CAS  WHEN: Fri Apr 19 16:51:01 CST 2019  CLIENT IP ADDRESS: 0:0:0:0:0:0:0:1  SERVER IP ADDRESS: 0:0:0:0:0:0:0:1  =============================================================    >  2019-04-19 16:51:01,300 INFO [org.apereo.inspektr.audit.support.Slf4jLoggingAuditTrailManager] - <Audit trail record BEGIN  =============================================================  WHO: admin  WHAT: TGT-**************************GHfz0lUJQE-8fkKJgyv8WXNE5FYLBqb7zfWGfNoKwDZ0AjqA-DESKTOP-GDU9JII  ACTION: TICKET_GRANTING_TICKET_CREATED  APPLICATION: CAS  WHEN: Fri Apr 19 16:51:01 CST 2019  CLIENT IP ADDRESS: 0:0:0:0:0:0:0:1  SERVER IP ADDRESS: 0:0:0:0:0:0:0:1  =============================================================    >  2019-04-19 16:51:01,307 INFO [org.apereo.cas.DefaultCentralAuthenticationService] - <Granted ticket [ST-35-Mf1v9Z2qVVVKlWeTgyc-Hlzh2xY-DESKTOP-GDU9JII] for service [http://localhost:8083/login/cas] and principal [admin]>  2019-04-19 16:51:01,308 INFO [org.apereo.inspektr.audit.support.Slf4jLoggingAuditTrailManager] - <Audit trail record BEGIN  =============================================================  WHO: admin  WHAT: ST-35-Mf1v9Z2qVVVKlWeTgyc-Hlzh2xY-DESKTOP-GDU9JII for http://localhost:8083/login/cas  ACTION: SERVICE_TICKET_CREATED  APPLICATION: CAS  WHEN: Fri Apr 19 16:51:01 CST 2019  CLIENT IP ADDRESS: 0:0:0:0:0:0:0:1  SERVER IP ADDRESS: 0:0:0:0:0:0:0:1  =============================================================

然後跳轉到service地址,也就是

localhost:8083/login/cas ,並帶上為這個service生成的service ticket,所以最後的請求地址為:

http://localhost:8083/login/cas?ticket=ST-35-Mf1v9Z2qVVVKlWeTgyc-Hlzh2xY-DESKTOP-GDU9JII

而這個端點/login/cas會被我配置的自定義CustomCasAuthenticationFilter攔截

@Override      public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {          HttpServletRequest req = (HttpServletRequest) request;          HttpServletResponse res = (HttpServletResponse) response;          if (!requiresAuthentication(req, res)) {              chain.doFilter(request, response);              return;          }          String ticket = obtainArtifact(req);          //開始校驗ticket          try {              CasInfo casInfo = (CasInfo) req.getSession().getAttribute("casInfoByTenantId");              if (StringUtils.hasText(casInfo.getCasServer())) {                  //獲取當前項目地址                  String service;                  int port = request.getServerPort();                  if (port != 80) {                      service = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + endpoint;                  } else {                      service = request.getScheme() + "://" + request.getServerName() + endpoint;                  }                  //開始校驗ticket                  Assertion validateResult = getTicketValidator(casInfo.getCasServer()).validate(ticket, service);                  //根據校驗結果,獲取用戶詳細信息                  UserDetails userDetails = null;                  try {                      userDetails = userDetailsService.loadUserByUsername(validateResult.getPrincipal().getName());                      if (this.logger.isDebugEnabled()) {                          logger.debug("userDetailsServiceImpl is loading username:"+validateResult.getPrincipal().getName());                      }                  } catch (UsernameNotFoundException e) {                      unsuccessfulAuthentication(req, res, e);                  }                  //手動封裝authentication對象                  assert userDetails != null;                  UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(validateResult.getPrincipal(), ticket, userDetails.getAuthorities());                  authentication.setDetails(userDetails);                  successfulAuthentication(req,res,chain,authentication);                  } else {                  unsuccessfulAuthentication(req, res, new BadCredentialsException("bad credential:ticket校驗失敗"));              }          } catch (TicketValidationException e) {              //ticket校驗失敗              unsuccessfulAuthentication(req, res, new BadCredentialsException(e.getMessage()));          }  //        chain.doFilter(request, response);      }

校驗成功之後,我的邏輯是,手動加載用戶信息,然後把當前認證信息Authentication放到SecurityContextHolder中。

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {          if (this.logger.isDebugEnabled()) {              this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);          }            SecurityContextHolder.getContext().setAuthentication(authResult);          if (this.eventPublisher != null) {              this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));          }            this.successHandler.onAuthenticationSuccess(request, response, authResult);      }        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {          SecurityContextHolder.clearContext();          if (this.logger.isDebugEnabled()) {              this.logger.debug("Authentication request failed: " + failed.toString(), failed);              this.logger.debug("Updated SecurityContextHolder to contain null Authentication");              this.logger.debug("Delegating to authentication failure handler " + this.failureHandler);          }            this.failureHandler.onAuthenticationFailure(request, response, failed);      }  

3.單點登出流程分析

用戶發送/logout請求,被我自定義的CustomLogoutFilter攔截

之後的邏輯是,先從本地登出,然後判斷之前是否是從cas認證的,如果是,再獲取cas信息,然後把cas也登出了。這裡判斷登陸用戶的認證方式,我想了很久,最後的實現思路如下:

之前通過cas登錄時,我手動的添加登陸用戶的認證方式到Authentication中。代碼如下:

//根據校驗結果,獲取用戶詳細信息  UserDetails userDetails = null;  try {      userDetails = userDetailsService.loadUserByUsername(validateResult.getPrincipal().getName());      if (this.logger.isDebugEnabled()) {          logger.debug("userDetailsServiceImpl is loading username:"+validateResult.getPrincipal().getName());      }  } catch (UsernameNotFoundException e) {      unsuccessfulAuthentication(req, res, e);  }    //手動封裝authentication對象  assert userDetails != null;  UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(validateResult.getPrincipal(), ticket, userDetails.getAuthorities());    //就是這裡做了文章  authentication.setDetails(userDetails);  successfulAuthentication(req,res,chain,authentication);

然後,登出時,拿到這個信息,進行登出操作。因為,我在userdetails中封裝了這個信息,所以可以拿到。

public class UserDetailsVO implements UserDetails {      //user      private Integer userId;        private String username;        private String phone;        private String email;        //tenant      private Tenant tenant;        //account      private Integer accountId;        private String password;      //省略setter和getter  }

項目源碼地址:https://github.com/lingEric/a2-oauth