經過對CAS架構的學習,模仿實現了基於Cookie和過濾器的單點登陸。而且利用Spring Boot中的自配置,來移除客戶端重複配置。java
package com.menghao.sso.client.filter; import com.menghao.sso.client.util.CommonUtils; import lombok.Setter; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.util.StringUtils; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; /** * <p>客戶端過濾器抽象類.<br> * * @author menghao. * @version 2017/11/15. */ public abstract class AbstractCasFilter implements Filter { protected final Log log = LogFactory.getLog(this.getClass()); /* 請求服務主機名 */ @Setter private String clientHost; /* cas服務端地址 */ @Setter protected String serverHost; private static final String HTTP = "http://"; // ...省略若干構建url方法 protected String makeOriginalRequest(HttpServletRequest request, HttpServletResponse response) { StringBuilder builder = new StringBuilder(); builder.append(request.isSecure() ? "https://" : "http://"); builder.append(clientHost); builder.append(request.getRequestURI()); // 若是存在查詢參數,將參數抽取拼接 if (StringUtils.hasLength(request.getQueryString())) { int index = request.getQueryString().indexOf(CommonUtils.ST_ID + "="); // 默認規則ticket放在查詢參數最後 if (index == -1) { builder.append("?").append(request.getQueryString()); } else if (index == 0) { // do nothing } else { index = request.getQueryString().indexOf("&" + CommonUtils.ST_ID + "="); if (index == -1) { builder.append("?").append(request.getQueryString()); } else { builder.append("?").append(request.getQueryString().substring(0, index)); } } } final String returnValue = response.encodeURL(builder.toString()); if (log.isDebugEnabled()) { log.debug("serviceUrl make: " + returnValue); } return returnValue; } protected abstract void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException; @Override public void init(FilterConfig filterConfig) throws ServletException { } /* 此步對request和response作了統一轉型 */ @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { doFilterInternal((HttpServletRequest) request, (HttpServletResponse) response, chain); } @Override public void destroy() { } }
抽象的主要目的,是爲了對ServletRequest 和 ServletResponse的統一轉型。其中代碼省略了不少構造url的方法:好比登陸、驗證、註銷等等。其中makeOriginalRequest是獲取本來的url請求(過濾掉ServiceTicket後的本來url請求),該方法構造的url會傳遞給服務端,方便登陸成功的重定向。git
package com.menghao.sso.client.filter; import com.menghao.sso.client.util.CommonUtils; import org.springframework.util.StringUtils; import org.springframework.web.util.WebUtils; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * <p>對TGT和ST有無校驗.<br> * * @author menghao. * @version 2017/11/15. */ public class AuthenticationFilter extends AbstractCasFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { Cookie cookie = WebUtils.getCookie(request, CommonUtils.TGT_ID); // 無TGT,說明未登陸 if (null == cookie || cookie.getValue() == null) { String originalRequest = makeOriginalRequest(request, response); // 沒有ticket則重定向登陸 String loginUrl = makeLoginRequest(originalRequest); response.sendRedirect(loginUrl); return; } // 有TGT,無ST,說明已登陸但登陸其餘系統 String serviceTicket = request.getParameter(CommonUtils.ST_ID); if (!StringUtils.hasText(serviceTicket)) { String originalRequest = makeOriginalRequest(request, response); String validateRequest = makeValidateTGTRequest(originalRequest, cookie.getValue()); response.sendRedirect(validateRequest); return; } // 具有TGT和ST filterChain.doFilter(request, response); } }
package com.menghao.sso.client.filter; import com.menghao.sso.client.model.ValidateBean; import com.menghao.sso.client.util.CommonUtils; import com.menghao.sso.client.validation.TicketValidator; import com.menghao.sso.client.validation.ValidationException; import lombok.Setter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * <p>對ST合法性校驗.<br> * * @author menghao. * @version 2017/11/15. */ public class CheckTicketFilter extends AbstractCasFilter { @Setter private TicketValidator ticketValidator; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { // 建立服務驗證請求 String serviceTicket = request.getParameter(CommonUtils.ST_ID); String originalRequest = makeOriginalRequest(request, response); String validateRequest = makeValidateSTRequest(originalRequest); // 發送驗證請求 try { ValidateBean validateBean = ValidateBean.builder().url(validateRequest).serviceTicket(serviceTicket).build(); Boolean success = ticketValidator.validate(validateBean); if (success) { filterChain.doFilter(request, response); return; } } catch (ValidationException e) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); log.warn(e, e); throw new ServletException(e); } } }
package com.menghao.sso.client.validation; import com.menghao.sso.client.model.ValidateBean; import lombok.Setter; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; /** * <p>抽象校驗類.<br> * * @author menghao. * @version 2017/11/16. */ public abstract class AbstractTicketValidator implements TicketValidator { @Setter protected RestTemplate restTemplate; @Setter protected String casServerUrl; /** * 模版模式 * * @param validateBean 驗證包裝類 * @return Boolean 是否驗證經過 * @throws ValidationException */ @Override public Boolean validate(ValidateBean validateBean) throws ValidationException { try { ResponseEntity<Boolean> responseEntity = restTemplate.getForEntity(validateBean.getUrl(), Boolean.class, validateBean.getServiceTicket()); return parseResponse(responseEntity); } catch (RestClientException e) { throw new ValidationException(e); } } protected abstract Boolean parseResponse(ResponseEntity<Boolean> responseEntity); }
介紹完客戶端的主要驗證方案,來看看如何將客戶端以插件的形式「配置」到各個須要驗證的模塊上。web
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>
package com.menghao.sso.client.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; /** * <p>客戶端配置屬性.<br> * * @author menghao. * @version 2017/11/6. */ @Data @ConfigurationProperties(prefix = "menghao.sso") public class SsoClientProperties { /* 客戶端<host>:<port> */ private String clientHost; /* 服務端<host>:<port> */ private String serverHost; /* 限制登陸url,逗號分割 */ private String restrictUrls; }
package com.menghao.sso.client.config; import com.menghao.sso.client.filter.AuthenticationFilter; import com.menghao.sso.client.filter.CheckTicketFilter; import com.menghao.sso.client.filter.WrapInfoFilter; import com.menghao.sso.client.validation.PersonTicketValidator; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.Assert; import org.springframework.web.client.RestTemplate; import java.util.Arrays; import java.util.List; /** * <p>客戶端自動配置類.<br> * * @author menghao. * @version 2017/11/16. */ @Configuration @EnableConfigurationProperties(SsoClientProperties.class) @ConditionalOnWebApplication @ConditionalOnProperty(prefix = "menghao.sso", value = "enabled", matchIfMissing = false) public class SsoClientAutoConfiguration { private SsoClientProperties ssoClientProperties; private List<String> urls; public SsoClientAutoConfiguration(SsoClientProperties ssoClientProperties) { this.ssoClientProperties = ssoClientProperties; String strictUrls = ssoClientProperties.getRestrictUrls(); Assert.hasText(ssoClientProperties.getClientHost(), "服務主機地址必須指定"); Assert.hasText(strictUrls, "攔截地址必須指定"); // 初始化時,會將配置須要攔截的url分割成列表 urls = Arrays.asList(strictUrls.split(",")); } @Bean @ConditionalOnMissingBean(AuthenticationFilter.class) public FilterRegistrationBean registerAuthenticationFilter() { FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); AuthenticationFilter authenticationFilter = new AuthenticationFilter(); authenticationFilter.setServerHost(ssoClientProperties.getServerHost()); authenticationFilter.setClientHost(ssoClientProperties.getClientHost()); filterRegistrationBean.setFilter(authenticationFilter); // 配置過濾器須要攔截的url列表 filterRegistrationBean.setUrlPatterns(urls); filterRegistrationBean.setOrder(1); return filterRegistrationBean; } // ...省略其餘過濾器配置 }
SsoClientProperties封裝了一些可配置的信息。spring
SsoClientAutoConfiguration則是真正自配置的實現。apache
最後一步,在路徑/resources/META-INF路徑下,建立spring.factories文件,並添加:json
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.menghao.sso.client.config.SsoClientAutoConfiguration
這樣,將客戶端打成Jar包,並在須要的模塊引入依賴,就能夠在Spring Boot啓動時,自動加載該配置類(前提是知足@ConditionOnXxx的條件)緩存
若是有額外的屬性須要指定,能夠經過additional-spring-configuration-metadata.json文件中聲明,該文件與spring.factories在同一級目錄,格式以下:markdown
{ "properties": [ { "name": "menghao.sso.enabled", "type": "java.lang.Boolean", "description": "自定義單點登陸客戶端.", "defaultValue": false } ] }
只須要在須要的模塊引入便可,指定服務端,客戶端地址,攔截的url請求正則。cookie
# true開啓,false關閉 menghao.sso.enabled = true menghao.sso.cas-server-host = localhost:1000 menghao.sso.cas-client-host = localhost:1001 # 配置攔截的url,多個用逗號分割 menghao.sso.restrict-urls = /*
其中使用到了兩種票據,TicketGrantingTicket (TGT)和 ServiceTicket(ST)。在登陸成功時,往Cookie中放入TGT,在url上拼接ST;在校驗時,若是有ST,直接校驗,若是沒有則獲取TGT,校驗經過後授予ST,一切校驗失敗的行爲都會拋出ValidateFailException異常(自定義),並交由異常統一處理返回登陸界面。架構
來看下主要的兩個業務實現類:
package com.menghao.sso.server.service; import com.menghao.sso.server.exception.ValidateFailException; import com.menghao.sso.server.model.Service; import com.menghao.sso.server.model.credentials.Credentials; import com.menghao.sso.server.model.credentials.UsernamePasswordCredentials; import com.menghao.sso.server.model.ticket.ServiceTicket; import com.menghao.sso.server.model.ticket.TicketGrantingTicket; import com.menghao.sso.server.registry.TicketRegistry; import com.menghao.sso.server.repository.UCredentialsRepository; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Autowired; /** * <p>認證Service實現.<br> * * @author menghao. * @version 2017/11/17. */ @org.springframework.stereotype.Service public class AuthenticationServiceImpl implements AuthenticationService { private final Log log = LogFactory.getLog(this.getClass()); @Autowired private UCredentialsRepository uCredentialsRepository; @Autowired private TicketRegistry ticketRegistry; @Override public void validateCredentials(Credentials credentials) throws ValidateFailException { if (credentials instanceof UsernamePasswordCredentials) { UsernamePasswordCredentials usernamePasswordCredentials = uCredentialsRepository.queryByProperty((UsernamePasswordCredentials) credentials); // 驗證經過 if (usernamePasswordCredentials == null) { throw new ValidateFailException("用戶名與密碼不匹配"); } } else { throw new ValidateFailException("暫不支持的認證方式"); } } @Override public void validateGrantingTicket(String ticketGrantingTicketId) throws ValidateFailException { if (ticketGrantingTicketId == null) { throw new ValidateFailException("未檢測到票據信息,請登陸"); } final TicketGrantingTicket ticketGrantingTicket = (TicketGrantingTicket) this.ticketRegistry.getTicket(ticketGrantingTicketId); if (ticketGrantingTicket == null) { log.debug("TicketGrantingTicket [" + ticketGrantingTicket + "] does not exist."); throw new ValidateFailException("票據信息校驗未經過,請登陸"); } if (ticketGrantingTicket.isExpired()) { log.debug("ServiceTicket [" + ticketGrantingTicket + "] has expired."); this.ticketRegistry.deleteTicket(ticketGrantingTicketId); throw new ValidateFailException("身份驗證已過時,請從新登陸"); } ticketGrantingTicket.updateLastTimeUsed(); } @Override public void validateServiceTicket(String serviceTicketId, Service service) throws ValidateFailException { if (serviceTicketId == null || service == null) { throw new ValidateFailException("未檢測到票據信息,請登陸"); } final ServiceTicket serviceTicket = (ServiceTicket) this.ticketRegistry.getTicket(serviceTicketId); if (serviceTicket == null) { log.debug("ServiceTicket [" + serviceTicketId + "] does not exist."); throw new ValidateFailException("票據信息校驗未經過,請登陸"); } if (serviceTicket.isExpired()) { log.debug("ServiceTicket [" + serviceTicketId + "] has expired."); this.ticketRegistry.deleteTicket(serviceTicketId); throw new ValidateFailException("身份驗證已過時,請從新登陸"); } serviceTicket.incrementCountOfUses(); serviceTicket.updateLastTimeUsed(); if (serviceTicket.isExpired()) { log.debug("ServiceTicket [" + serviceTicketId + "] has expired."); this.ticketRegistry.deleteTicket(serviceTicketId); throw new ValidateFailException("身份驗證已過時,請從新登陸"); } if (!service.equals(serviceTicket.getService())) { log.debug("ServiceTicket [" + serviceTicketId + "] does not match supplied service."); throw new ValidateFailException("票據信息與服務不匹配,請登陸"); } } }
package com.menghao.sso.server.service; import com.menghao.sso.server.exception.InvalidTicketException; import com.menghao.sso.server.exception.ValidateFailException; import com.menghao.sso.server.model.Principal; import com.menghao.sso.server.model.Service; import com.menghao.sso.server.model.SimplePrincipal; import com.menghao.sso.server.model.credentials.Credentials; import com.menghao.sso.server.model.credentials.UsernamePasswordCredentials; import com.menghao.sso.server.model.ticket.ServiceTicket; import com.menghao.sso.server.model.ticket.TicketGrantingTicket; import com.menghao.sso.server.model.ticket.TicketGrantingTicketImpl; import com.menghao.sso.server.model.validation.Authentication; import com.menghao.sso.server.registry.ExpirationPolicy; import com.menghao.sso.server.registry.TicketRegistry; import com.menghao.sso.server.util.TicketIdGenerator; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; import java.util.HashMap; /** * <p>受權Service實現.<br> * * @author menghao. * @version 2017/11/17. */ @org.springframework.stereotype.Service public class AuthorizationServiceImpl implements AuthorizationService { private final Log log = LogFactory.getLog(this.getClass()); @Autowired private TicketRegistry ticketRegistry; @Autowired private ExpirationPolicy expirationPolicy; @Override public String createTicketGrantingTicket(Credentials credentials) throws ValidateFailException { if (credentials instanceof UsernamePasswordCredentials) { String username = ((UsernamePasswordCredentials) credentials).getUsername(); if (!StringUtils.hasText(username)) { throw new ValidateFailException("沒法獲取用戶名"); } String tgtId = TicketIdGenerator.newTGTId(); Principal principal = new SimplePrincipal(username); Authentication authentication = new Authentication(principal, new HashMap()); ticketRegistry.addTicket(new TicketGrantingTicketImpl(tgtId, authentication, this.expirationPolicy)); return tgtId; } return null; } @Override public String createServiceTicket(String ticketGrantingTicketId, Service service) throws ValidateFailException { if (!StringUtils.hasText(ticketGrantingTicketId)) { throw new ValidateFailException("未檢測到票據信息,請登陸"); } TicketGrantingTicket ticketGrantingTicket = (TicketGrantingTicket) ticketRegistry.getTicket(ticketGrantingTicketId); if (ticketGrantingTicket == null) { throw new ValidateFailException("票據信息校驗未經過,請登陸"); } if (ticketGrantingTicket.isExpired()) { throw new ValidateFailException("身份驗證已過時,請從新登陸"); } final ServiceTicket serviceTicket = ticketGrantingTicket.grantServiceTicket( TicketIdGenerator.newSTId(), service, this.expirationPolicy); this.ticketRegistry.addTicket(serviceTicket); log.info("Granted service ticket [" + serviceTicket.getId() + "] for service [" + service.getUrl() + "] for user [" + serviceTicket.getGrantingTicket().getAuthentication() .getPrincipal().getUrl() + "]"); return serviceTicket.getId(); } @Override public void destroyTicketGrantingTicket(String ticketGrantingTicketId) throws InvalidTicketException { if (!StringUtils.hasText(ticketGrantingTicketId)) { throw new InvalidTicketException(); } ticketRegistry.deleteTicket(ticketGrantingTicketId); } }
這兩個類中幾乎包含了全部的服務端處理邏輯,Controller層就是藉助這兩個類的方法,拼裝後實現的。其中注入的 TicketRegistry(票據註冊)和 ExpirationPolicy(票據過時)代碼講解在下面。
package com.menghao.sso.server.exception; import lombok.Getter; /** * <p>校驗失敗異常.<br> * * @author menghao. * @version 2017/11/20. */ public class ValidateFailException extends Exception { public ValidateFailException(String msg) { super(); this.msg = msg; } public ValidateFailException(String service, String msg) { this(msg); this.service = service; } @Getter private String msg; @Getter private String service; }
package com.menghao.sso.server.controller.advice; import com.menghao.sso.server.exception.ValidateFailException; import com.menghao.sso.server.util.CommonUtils; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.servlet.ModelAndView; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; /** * <p>異常統一處理類.<br> * * @author menghao. * @version 2017/11/20. */ @ControllerAdvice public class ExceptionController { @ExceptionHandler(ValidateFailException.class) public ModelAndView validateException(ValidateFailException e) throws UnsupportedEncodingException { ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject("msg", URLEncoder.encode(e.getMsg(), "UTF-8")); modelAndView.addObject(CommonUtils.SERVICE, e.getService()); modelAndView.setViewName("redirect:" + CommonUtils.LOGIN_URL); return modelAndView; } }
除了驗證經過的其餘任何狀況,如過時,不存在等等,都會拋出 ValidateFailException 異常,經過異常統一處理,重定向至登陸頁,並將提示信息封裝到 request 域中。
爲了方便橫向擴展,將註冊策略抽象爲接口,目前只實現了一種:
package com.menghao.sso.server.registry; import com.menghao.sso.server.model.ticket.Ticket; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; /** * <p>默認的票據註冊實現.<br> * * @author menghao. * @version 2017/11/21. */ public final class DefaultTicketRegistry implements TicketRegistry { private final Log log = LogFactory.getLog(getClass()); private final Map<String, Ticket> cache = new HashMap<String, Ticket>(); public synchronized void addTicket(final Ticket ticket) { if (ticket == null) { throw new IllegalArgumentException("ticket cannot be null"); } log.debug("Added ticket [" + ticket.getId() + "] to registry."); this.cache.put(ticket.getId(), ticket); } public synchronized Ticket getTicket(final String ticketId) { log.debug("Attempting to retrieve ticket [" + ticketId + "]"); final Ticket ticket = this.cache.get(ticketId); if (ticket != null) { log.debug("Ticket [" + ticketId + "] found in registry."); } return ticket; } public synchronized boolean deleteTicket(final String ticketId) { log.debug("Removing ticket [" + ticketId + "] from registry"); return (this.cache.remove(ticketId) != null); } public synchronized Collection getTickets() { return Collections.unmodifiableCollection(this.cache.values()); } }
爲了方便橫向擴展,將過時策略抽象爲接口,目前只實現了兩種:
package com.menghao.sso.server.registry; import com.menghao.sso.server.model.ticket.Ticket; /** * 基於過時時間:最近一次使用的使用 */ public final class TimeoutExpirationPolicy implements ExpirationPolicy { private final long timeToKillInMilliSeconds; public TimeoutExpirationPolicy(final long timeToKillInMilliSeconds) { this.timeToKillInMilliSeconds = timeToKillInMilliSeconds; } public boolean isExpired(final Ticket ticket) { return (ticket == null) || (System.currentTimeMillis() - ticket.getLastTimeUsed() >= this.timeToKillInMilliSeconds); } }
package com.menghao.sso.server.registry; import com.menghao.sso.server.model.ticket.Ticket; /** * 永不過時 */ public final class NeverExpirationPolicy implements ExpirationPolicy { public boolean isExpired(final Ticket ticket) { return false; } }
package com.menghao.sso.server.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; /** * <p>服務端配置屬性Bean.<br> * * @author menghao. * @version 2017/11/17. */ @Data @ConfigurationProperties(prefix = "menghao.sso.server") public class SsoServerProperties { /* 緩存策略 */ private String ticketCache = "default"; }
package com.menghao.sso.server.config; import com.menghao.sso.server.registry.*; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * <p>服務端自配置類<br> * * @author menghao. * @version 2017/11/17. */ @Configuration public class SsoServerAutoConfiguration { @Bean @ConditionalOnProperty(prefix = "menghao.sso.server", name = "ticketCache", havingValue = "default") public TicketRegistry defaultRegistry() { return new DefaultTicketRegistry(); } @Bean public ExpirationPolicy expirationPolicy() { return new TimeoutExpirationPolicy(1000 * 60 * 60); } }
默認註冊策略,採用內存放置Map的形式,存儲 ticketId-Ticket鍵值對。默認的過時策略,1小時的間隔使用時間。
目前只完成了單個請求的校驗邏輯,若是是服務間調用,按照Cas本來架構中,是以代理票據實現的,目前還不能支持。該架構只是爲了可以對Cas架構有更好的理解,而進行的拆分整理,單純的實現了基本功能,對於併發等狀況未作考慮。