spring security集成cas

spring security集成cas

源碼地址在文章末尾,轉載請註明出處,謝謝。html

0.配置本地ssl鏈接

操做記錄以下:java

=====================1.建立證書文件thekeystore ,並導出爲thekeystore.crt
cd C:\Users\23570\keystore

C:\Users\23570\keystore>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:\Users\23570\keystore>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:\Users\23570\keystore>keytool -import -alias thekeystore -storepass changeit -file thekeystore.crt -keystore "C:\Program Files\Java\jdk1.8.0_191\jre\lib\security\cacerts"
全部者: 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:\Users\23570\keystore\thekeystore"
           keystorePass="changeit"/>
           
==========================4.其餘命令參考
刪除JRE中指定別名的證書
keytool -delete -alias cas.server.com -keystore "C:\Program Files\Java\jdk1.8.0_191\jre\lib\security\cacerts"

查看JRE中指定別名的證書
keytool -list -v -keystore "C:\Program Files\Java\jdk1.8.0_191\jre\lib\security\cacerts" -alias cas.server.com

1.cas服務搭建

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

注意:mysql

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

1.使用數據庫帳號密碼登陸cas

導入依賴github

<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>

配置查詢web

#這裏是配置用戶表單登陸時用戶名字段爲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', 'donglin.ling@hand-china.com');
INSERT INTO `oauth_user` VALUES (2, 'admin', '15797656201', 'ericling666@gmail.com');

SET FOREIGN_KEY_CHECKS = 1;

發佈cas server,訪問:spring

https://localhost:8443/cas/loginsql

測試帳號和密碼,admin:123456shell

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/...

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);
        }
    
        /**
*/
   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個租戶的配置以下:

![](http://ww1.sinaimg.cn/large/006edVQGgy1g27zywkcz7j30cy02o3ye.jpg)

其中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.

![](http://ww1.sinaimg.cn/large/006edVQGgy1g280cpeufpj311y0jvwju.jpg)

能夠看到,已經進入到了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";

}

用戶跳轉到登錄頁面

![](http://ww1.sinaimg.cn/large/006edVQGgy1g280pr0ws7j30gq0bz3yn.jpg)

輸入用戶名密碼,點擊登錄,進入`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

![](http://ww1.sinaimg.cn/large/006edVQGgy1g281gblfkgj311y0jvwjp.jpg)

輸入用戶名密碼,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攔截

![](http://ww1.sinaimg.cn/large/006edVQGgy1g281qb9jqnj311y0jvjwt.jpg)

@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`攔截

![](http://ww1.sinaimg.cn/large/006edVQGgy1g281vaf77xj311y0jv0y7.jpg)

以後的邏輯是,先從本地登出,而後判斷以前是不是從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

}

相關文章
相關標籤/搜索