经过对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架构有更好的理解,而进行的拆分整理,单纯的实现了基本功能,对于并发等状况未作考虑。