spring security整合cas實現單點登入
spring security整合cas
0.配置本地ssl連線
操作記錄如下:
=====================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
注意:
這裡選用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檔案註冊服務,實際專案中,可以配置成從資料庫中註冊
新增json支援依賴
<!--json服務註冊--> <dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-json-service-registry</artifactId> <version>${cas.version}</version> </dependency>
新增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註冊檔案,沒有限制域名,也就是說所有的服務都可以註冊成功。
開啟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
配置說明:
配置cas服務登出時,是否跳轉到各個子服務的登出頁面,預設false【即預設情況下,子服務點選登出,使用者統一跳轉到cas的登出頁面】,子服務登出時訪問cas登出端點,並帶上service。
示例:
https://localhost:8443/cas/logout?service=http://localhost:8083/logout/success
這樣配置,cas登出session之後,會重定向到service。
這個欄位可以配置,預設是service。配置如下:
cas.logout.redirectParameter=service
配置service ticket的失效時間,我這裡配置這個選項,是為了方便後面debug除錯,實際生產中,不必配置這個選項。
更多常用配置項,請檢視官網連結:https://apereo.github.io/cas/5.3.x/installation/Configuration-Properties.html
2.spring security和cas整合
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>
application.yml配置
# 我這裡是為了方便除錯 logging.level.org.springframework.security: debug logging.level.web: debug
2.配置登入端點
spring security開啟表單登陸
@Override protected void configure(HttpSecurity http) throws Exception { http.formLogin().loginPage("/login"); }
這個配置,會開啟使用者表單登入,並且配置登入端點為
/login
配置登入端點響應邏輯
@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
,有兩種情況cas
,oauth
cas
:租戶有自己的cas單點登入系統,平臺需要和租戶的cas整合oauth
:租戶沒有cas,使用平臺統一的表單登陸
具體的登入流程分析,在最後詳細介紹,這裡不過多講解。
3.配置CAS的ticket校驗以及登入響應
自定義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); } }
把自定義的
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.配置單點登出
自定義實現
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); } }
把
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")));
}
}
}
這裡對異常的處理,其實,核心就只有兩個方法:
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
,這種情況下,使用者已經登陸了,但是許可權不夠,所以交給accessDeniedHandler進行處理,一般來講,如果沒有進行特殊的配置,會返回一個403錯誤和異常資訊【不再跳轉到authentication entry point,因為使用者已經登陸了】,這裡不深究。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-oa