简单的利用cas实现不同系统之间的单点登陆

最近需要为多个不同系统提供一个统一认证的平台(这里的不同系统是各自有独立的用户名和密码的系统),看了一圈后决定采用 https://apereo.github.io/cas/4.2.x/ 本来打算采用5.1的,发现它需要java8 的支持,最终决定采用了4.2.7。

关于cas的安装:https://apereo.github.io/cas/4.2.x/installation/Maven-Overlay-Installation.html , 光是安装这玩意就用了我半天的时间。。。 安装好之后会生成一个war包,解压之后如下:

QQ截图20170630221358.png

扔到tomcat里面直接就能跑了,但它默认只支持https协议,可以参考 https://www.cnblogs.com/xiaojf/p/6617693.html 这篇文章让它支持http,https当然更好 但是这里需要多个厂家多个系统之间的配合,能多简单就多简单。

基本的配置完成之后就是一个绑定、登录以及注销流程的设计:
账号绑定流程如下:
cas账号绑定.png
登录流程图如下:
cas登录逻辑 (1).png

注销相对简单,流程如下:
cas注销流程图

从上面的交互中可以看出,需要子系统提供两个查询用户信息的接口,用来绑定和查询,由于只暴露用户的id和name属性,安全方面应该不会有什么问题。值得注意的是,如果用户在统一认证平台登录成功了,但是目标系统 账号没有和统一认证平台的账号绑定,或者绑定的账号已经被删除,此时只能走子系统以前的登录逻辑,因为如果此时子系统仍然将登录请求重定向到认证平台,认证平台发现账号已经登录,再次重定向到子系统,会发生死循环

下面是实现过程中的一些组件:

1.从数据库中加载 AbstractRegisteredService

系统默认AbstractRegisteredService的实现是 org.jasig.cas.services.JsonServiceRegistryDao 它默认会将 /WEB-INF/classes/services 下的两个json文件映射 成 AbstractRegisteredService,(我一开始以为RegisteredService 只有一个,后来才明白,我的情况需要每个子系统单独配置一个,一个RegisteredService既可以对应单独的子系统,也可以为对应多个子系统)。 替换默认的JsonServiceRegistryDao也很简单,创建 TestServiceRegistryDao,然后在 deployerConfigContext.xml 中调整如下:

 <alias name="jsonServiceRegistryDao" alias="serviceRegistryDao" />
改为:
 <alias name="testCasRegisteServiceDao" alias="serviceRegistryDao" />

TestServiceRegistryDao 如下:

@Component("testCasRegisteServiceDao")
public class TestServiceRegistryDao implements ServiceRegistryDao, InitializingBean {

	@Autowired
	private CasRegisteredServiceDao registerServiceDao;

	private Map<Long, RegisteredService> cache = new ConcurrentHashMap<>();

	@Override
	public boolean delete(RegisteredService rs) {
		return false;
	}

	@Override
	public RegisteredService findServiceById(long id) {
		return cache.get(id);
	}

	@Override
	public List<RegisteredService> load() {
		return new ArrayList<>(cache.values());
	}

	@Override
	public RegisteredService save(RegisteredService service) {
		return null;
	}

	@Override
	public void afterPropertiesSet() throws Exception {
		List<RegisterService> services = registerServiceDao.findAll();
		for (RegisterService service : services) {
			cache.put(service.getId(), new TestCasRegisteredService(service));
		}
	}
}

其实cas-server本身就提供了从数据库加载 AbstractRegisteredService的服务,看了 AbstractRegisteredService源码后可以发现它本身也是JPA的一个Entity,我这里为了简单易控自己实现了一下,如果需要官方的实现,可以参考: https://apereo.github.io/cas/4.2.x/installation/JPA-Service-Management.html

2.自定义校验cas登录用户名和密码

通过创建一个名为 primaryAuthenticationHandler 的AuthencationHandler即可自定义校验cas登录用户名和密码

@Component("primaryAuthenticationHandler")
public class TestCasAuthencationHandler extends AbstractUsernamePasswordAuthenticationHandler {

	@Autowired
	private UserDao userDao;

	@Override
	protected HandlerResult authenticateUsernamePasswordInternal(UsernamePasswordCredential credential)
			throws GeneralSecurityException, PreventedException {
		String username = credential.getUsername();
		String encryptedPassword = this.getPasswordEncoder().encode(credential.getPassword());

		User user = userDao.findUser(username);
		if (user == null) {
			throw new AccountNotFoundException(username + "不存在");
		}
		if (!user.getPassword().equals(encryptedPassword)) {
			throw new FailedLoginException("登录失败");
		}
		return createHandlerResult(credential, this.principalFactory.createPrincipal(username), null);
	}

}

3.额外的票根校验

实现和子系统认证最关键部分在于票根的校验,如果此时仍然走cas本来的校验过程,会忽略当前已经登录账号是否和子系统绑定的校验。
这里我选择了覆盖CentralAuthenticationServiceImpl的validateServiceTicket方法,简单逻辑如下:

public class TestCentralAuthenticationServiceImpl extends CentralAuthenticationServiceImpl{

	/**
	 * 
	 */
	private static final long serialVersionUID = 1L;

	@Override
	public Assertion validateServiceTicket(String serviceTicketId, Service service) throws AbstractTicketException {
		Assertion assertion = super.validateServiceTicket(serviceTicketId, service);
		//额外验证
		if(!bind(assertion.getPrimaryAuthentication().getPrincipal(), service.getId())){
			throw new UnbindAccountException("INVALID_SERVICE_USER", 
					String.format("账户[%s]没有和子系统[%s]绑定", assertion.getPrimaryAuthentication().getPrincipal().getId(),service.getId()));
		}
		return assertion;
	}
	
	public final class UnbindAccountException extends AbstractTicketException{

		public UnbindAccountException(String code,String msg) {
			super(code,msg);
		}
		
	}
	
	/**
	 * 校验当前用户是否已经和目标子系统绑定
	 * @param principal
	 * @param serviceId
	 * @return
	 */
	private boolean bind(Principal principal,String serviceId){
		return false;
	}

}

配置文件:

<bean id="centralAuthenticationService" class="test.TestCentralAuthenticationServiceImpl"/>

注意:如果通过了

super.validateServiceTicket(serviceTicketId, service)

的校验,此时系统会发送一个账号校验成功的事件 CasServiceTicketValidatedEvent ,此时有可能在接下来的校验中没有通过,所以需要在事件中额外判断!(也可以通过完全重写来避免)

4.校验失败错误信息的返回

通过修改WEB-INF\view\jsp\protocol下的jsp文件就可以修改返回的错误信息,例如:

<%@ page session="false" contentType="application/json;charset=UTF-8"%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
{"success":false,"data":{"code":"${code}","msg":"${fn:escapeXml(description)}"}}

这里说下这边的code,我在validateServiceTicket抛出了自定义的AbstractTicketException异常,该异常的构造方法接收一个code参数,我在自定义异常修改了该code之后发现没有起作用,还是返回了INVALID_TICKET的错误码,通过 查看 AbstractServiceValidateController源码之后才发现,AbstractTicketException异常的code属性并不是错误码,而应该是资源文件的code码,同时也没有在AbstractServiceValidateController源码中发现可以返回自定义错误码的地方

  } catch (final AbstractTicketValidationException e) {
            final String code = e.getCode();
            return generateErrorView(code, CasProtocolConstants.ERROR_CODE_INVALID_TICKET,
                    new Object[]{serviceTicketId, e.getOriginalService().getId(), service.getId()}, request, service);
        } catch (final AbstractTicketException te) {
            return generateErrorView(te.getCode(), CasProtocolConstants.ERROR_CODE_INVALID_TICKET,
                    new Object[]{serviceTicketId}, request, service);
        } catch (final UnauthorizedProxyingException e) {
            return generateErrorView(e.getCode(), CasProtocolConstants.ERROR_CODE_INVALID_REQUEST_PROXY, 
                    new Object[]{service.getId()}, request, service);
        } catch (final UnauthorizedServiceException e) {
            return generateErrorView(e.getCode(), CasProtocolConstants.ERROR_CODE_UNAUTHORIZED_SERVICE, null, request, service);
        }

通过异常处理可以发现,错误码是固定的,也就那么3种错误码,为了能够增加自己的错误码,我非常暴力的将 cas-server-webapp-validation 下的源码拷贝到了自己的项目中,同时删除了lib下的 cas-server-webapp-validation-4.2.7.jar, 在AbstractServiceValidateController中增加了自己的异常处理,由于时间紧迫,如果有更好的实现方法烦请告知。

最近需要在子系统账号没有和平台账号绑定时,子系统主动向服务器发出绑定账号的请求,即:
QQ截图20170714183235.png

此时需要子系统账号向平台传递serviceId(子系统的统一登录地址)、子系统账号id、子系统账号名以及cas服务器发送的票据,服务器接收到票据之后,根据票据和serviceId查询平台账户信息,根据查询出的平台账户信息和子系统发送来的账号绑定, 但是这时候cas服务的TicketRegistry已经移除了上面向子系统发送的票据(票据在validateServiceTicket验证之后,如果标记为过期就会被移除,同时主票据上持有service票据和service的映射),为了解决这个问题,只能额外的设置一个短时间的 缓存。 所以将validateServiceTicket重写如下:

@Override
	public Assertion validateServiceTicket(String serviceTicketId, Service service) throws AbstractTicketException {
		Assertion assertion = super.validateServiceTicket(serviceTicketId, service);
		//额外验证
		if(!bind(assertion.getPrimaryAuthentication().getPrincipal(), service.getId())){
        	ServiceTicketCache.getCache().put(
					new TicketKey(assertion.getPrimaryAuthentication().getPrincipal().getId(), service.getId()),
					serviceTicketId);
			throw new UnbindAccountException("INVALID_SERVICE_USER", 
					String.format("账户[%s]没有和子系统[%s]绑定", assertion.getPrimaryAuthentication().getPrincipal().getId(),service.getId()));
		}
		return assertion;
	}

cas服务器接收到绑定请求之后,从ServiceTicketCache中查询票据:

	BindInfo info = parse(request);
	TicketKey key = ServiceTicketCache.getCache().get(info.getTicket(), info.getServiceId());
	if (key == null) {
		return new JsonResult(false, "无效的ticket...");
	}
	userService.bindApplication(key.getUsername(), info.getServiceId(), info.getId(), info.getUsername());

ServiceTicketCache简单实现如下:

public class ServiceTicketCache {

	private static final ServiceTicketCache ins = new ServiceTicketCache();

	private ServiceTicketCache() {
		super();
	}

	public static ServiceTicketCache getCache() {
		return ins;
	}

	private final Cache<TicketKey, String> cache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.SECONDS)
			.build();

	/**
	 * 
	 * @param ticket
	 */
	public void put(TicketKey key, String ticket) {
		cache.put(key, ticket);
	}
	
	public TicketKey get(String ticket, String serviceId) {
		for (Map.Entry<TicketKey, String> it : cache.asMap().entrySet()) {
			if (ticket.equals(it.getValue()) && it.getKey().serviceId.equals(serviceId)) {
				return it.getKey();
			}
		}
		return null;
	}

	public static final class TicketKey {
		private final String username;
		private final String serviceId;

		public TicketKey(String username, String serviceId) {
			super();
			this.username = username;
			this.serviceId = serviceId;
		}

		public String getUsername() {
			return username;
		}

		public String getServiceId() {
			return serviceId;
		}

		@Override
		public int hashCode() {
			return Objects.hash(username, serviceId);
		}

		@Override
		public boolean equals(Object obj) {
			if (this == obj)
				return true;
			if (obj == null)
				return false;
			if (getClass() != obj.getClass())
				return false;
			TicketKey other = (TicketKey) obj;
			return Objects.equals(this.username, other.username) && Objects.equals(this.serviceId, other.serviceId);
		}

	}
}

可能存在一个票根重复的问题!

5.Service授权校验

由于此次系统本身并不是单纯的cas登录,带有一定的权限校验,现在要求如下:只有与Service绑定的用户才可以与该系统进行交互登录,这样就需要修改cas原本的登录流程: 1.首先重写 centralAuthenticationService 的票根授权逻辑:

@Override
	@Transactional(readOnly = true)
	public ServiceTicket grantServiceTicket(String ticketGrantingTicketId, Service service,
			AuthenticationContext context) throws AuthenticationException, AbstractTicketException {
		Principal principal = context.getAuthentication().getPrincipal();
		User user = userDao.findUser(principal.getId());
		if (unbindCheck) {
			throw new UnbindException(user, application);
		}
		return super.grantServiceTicket(ticketGrantingTicketId, service, context);
	}

这里之所以需要抛出一个自定义的UnbindException异常,是因为在登录的webflow中,负责授权的是 GenerateServiceTicketAction,在它的处理逻辑中,只会对 AuthenticationExceptionAbstractTicketException进行处理:

} catch (final AuthenticationException e) {
			logger.error("Could not verify credentials to grant service ticket", e);
		} catch (final AbstractTicketException e) {
			if (e instanceof InvalidTicketException) {
				this.centralAuthenticationService.destroyTicketGrantingTicket(ticketGrantingTicket);
			}
			if (isGatewayPresent(context)) {
				return result("gateway");
			}

			return newEvent("error", e);
		}

这两种异常处理都不是我需要的,所以需要增加一个自定义异常的处理,但偏偏 GenerateServiceTicketAction是final的,所以只能新建一个bean(篇幅所限,代码就不贴了,就是单纯的拷贝GenerateServiceTicketAction的源码), 增加完自定义的异常处理之后,需要修改WebContent/WEB-INF/webflow/login/login-webflow.xml 这个文件中的流程,具体如下:

<action-state id="generateServiceTicket">
		<evaluate expression="hlGenerateServiceTicketAction" />
		<transition on="success" to="warn" />
		<transition on="authenticationFailure" to="handleAuthenticationFailure" />
		<transition on="error" to="initializeLogin" />
		<transition on="invalid" to="invalidUser" />
		<transition on="gateway" to="gatewayServicesManagementCheck" />
	</action-state>

这里的invalid是我处理完UnbindException之后返回的,用来重定向页面,所以需要新增一个end-state:

<end-state id="invalidUser" view="externalRedirect:/ks/cas/invalid" />

至此,如果用户尝试通过cas登录没有被授权的子系统,会被重定向到 ks/cas/invalid,并且不会产生额外的service票根。

6.RegisteredService动态新增|刪除

一直以为RegisteredService是通过ServiceRegistryDao来存储的,实际上ServiceRegistryDao是底层的存储,DefaultServicesManagerImpl在此基础上另加了一层缓存, 之前一直不明白ServiceRegistryDao中的delete方法和save方法有什么用,看了DefaultServicesManagerImpl才知道,它们是为它服务的。也就是说,RegisteredService的变更操作 应该在ServicesManager中进行,ServicesManager内部会调用ServiceRegistryDao来操作,弄清楚这一点事情就非常简单了,单纯的调用DefaultServicesManagerImpl的save和delete方法 即可完成操作(没有更新操作),但这里面还有一个坑。。。 在进行删除操作的时候抛出了如下异常:

18:30:47.532 ERROR org.slf4j.impl.CasDelegatingLogger 405 error - 'resourceOperatedUpon' cannot be null.
Check the correctness of @Audit annotation at the following audit point: execution(public synchronized org.jasig.cas.services.RegisteredService org.jasig.cas.services.DefaultServicesManagerImpl.delete(long))
java.lang.IllegalArgumentException: 'resourceOperatedUpon' cannot be null.
Check the correctness of @Audit annotation at the following audit point: execution(public synchronized org.jasig.cas.services.RegisteredService org.jasig.cas.services.DefaultServicesManagerImpl.delete(long))

看了它源码确实发现有个Audit的annotation

 @Audit(action = "DELETE_SERVICE", actionResolverName = "DELETE_SERVICE_ACTION_RESOLVER",
            resourceResolverName = "DELETE_SERVICE_RESOURCE_RESOLVER")

搜了一下Audit这玩意叫什么审计日志,google了半天也没看到源码(github上面有个,但cas的应该是在此基础上修改过的),无奈之下看了 WEB-INF/spring-configuration/auditTrailContext.xml 这个文件,这才发现,里面根本就没有 DELETE_SERVICE_ACTION_RESOLVER和DELETE_SERVICE_RESOURCE_RESOLVER这两个值对应的Resolver。。。 这可能跟cas的版本或者编译情况有关,真是不明白为什么save方法有但是delete方法就没有了,在这上面卡了好久。
了解这个情况之后自定义一个Resover就可以解决问题了:

@Component("deleteServiceResourceResolver")
public class HLDeleteServiceResourceResolver implements AuditResourceResolver {

	@Override
	public String[] resolveFrom(JoinPoint jp, Object o) {
		return new String[0];
	}

	@Override
	public String[] resolveFrom(JoinPoint jp, Exception o) {
		return new String[0];
	}

}

同时在 WEB-INF/spring-configuration/auditTrailContext.xml 中的 auditActionResolverMap 中增加:

 <entry key="DELETE_SERVICE_ACTION_RESOLVER">
            <ref bean="authenticationActionResolver"/>
        </entry>

在auditResourceResolverMap中增加:

  <entry key="DELETE_SERVICE_RESOURCE_RESOLVER">
            <ref bean="deleteServiceResourceResolver"/>
        </entry>

这样删除操作就没有问题了。

另外另有个问题也困扰了好一会,在没有通过认证的时候,我发现访问未授权的 RegisteredService时,会显示如下页面:

QQ截图20170803184321.png

但是我通过认证之后,再访问未授权的 RegisteredService时,变成了如下页面,同时抛出了一个异常。。。

QQ截图20170803184230.png

看了半天登录流程才发现,通过认证之后除非携带renew参数,否则的话是不会校验 RegisteredService的,这里我简单粗暴的修改如下:

	<decision-state id="hasServiceCheck">
		<if test="flowScope.service != null" then="serviceAuthorizationCheck" else="viewGenericLoginSuccess" />
	</decision-state>

上面的做法是存在问题的,renew之后每次都会要求重新登录?

7.ticket和应用session

由于此次系统实在cas系统上做的二次开发,所以有着session的概念,但是在cas中,是通过cookie来保存ticket(可参考CookieRetrievingCookieGenerator),这样如果两者不一致(过期时间),将会导致一些问题,最明显 的就是无限重定向,产生无限重定向的原因就是session过期了,系统从session中获取不到用户信息,跳转到登录页,但此时cas能从请求中获取到ticket,也就是说此时用户只在应用中过期了,但还是在有效的认证时间内, 这将导致cas将登录地址重定向到某个地址,如果这个地址需要用户登录,那么就会死循环。这个问题实际中应该很少碰到,也很难发现,但巧就巧在cas默认的session过期时间只有5min,因此在实际的开发过程中还是能够碰到的, 要解决这个问题也很简单,但首先得知道cas是如何从请求中获取票据的,通过查看登录流程可以发现,CookieRetrievingCookieGenerator#retrieveCookieValue(HttpServletRequest request)可以获取ticket,如果ticket存在, 那么拿到ticket之后再通过CentralAuthenticationService获取用户凭证,通过凭证查询用户信息,再将用户信息放入session中即可’再次登录’,通过如果ticket比session早过期,同样需要判断,部分判断逻辑如下(我是在spring的拦截器中判断的):

HttpSession session = request.getSession(false);
if (session != null) {
	User user = (User) session.getAttribute(Constants.USER_SESSION_KEY);
	if (user != null) {
		UserContext.set(user);
	}
}

String ticket = ticketGrantingTicketCookieGenerator.retrieveCookieValue(request);
if (UserContext.get() == null) {
	if (ticket != null) {
		TicketGrantingTicket _ticket = centralAuthenticationService.getTicket(ticket,
				TicketGrantingTicket.class);
		if (_ticket != null) {
			String name = _ticket.getAuthentication().getPrincipal().getId();
			User user = userService.getUserByName(name);
			if (user != null && !user.isDisable()) {
				request.getSession().setAttribute(Constants.USER_SESSION_KEY, user);
				UserContext.set(user);
			}
		}
	}
} else {
	if (ticket == null) {
		if (session != null) {
			try {
				session.invalidate();
			} catch (Exception e) {
			}
		}
		UserContext.remove();
	}
}

关于在cas认证通过后同时保存在session中,可以这样做:

@Component("hlStorUserAction")
public final class HLStoreUserAction extends AbstractAction {
	private static final Logger LOGGER = LoggerFactory.getLogger(HLStoreUserAction.class);

	@Autowired
	private UserDao userDao;
	@Autowired
	private CentralAuthenticationService centralAuthenticationService;

	@Override
	protected Event doExecute(final RequestContext context) {
		String ticketGrantingTicketId = context.getFlowScope().getRequiredString("ticketGrantingTicketId");
		try {
			final TicketGrantingTicket ticketGrantingTicket = this.centralAuthenticationService
					.getTicket(ticketGrantingTicketId, TicketGrantingTicket.class);
			String username = ticketGrantingTicket.getAuthentication().getPrincipal().getId();
			HttpSession session = ((HttpServletRequest) context.getExternalContext().getNativeRequest())
					.getSession(true);
			session.setAttribute(Constants.USER_SESSION_KEY, userDao.findUserInTransaction(username));
		} catch (final InvalidTicketException e) {
			LOGGER.warn(e.getMessage(), e);
		}
		return new Event(this, "hlStoreUserSuccess");
	}

}

同时修改login-webflow.xml如下:

<action-state id="sendTicketGrantingTicket">
	<evaluate expression="hlStorUserAction" />
	<transition to="hlStoreUserSuccess" />
</action-state>

<action-state id="hlStoreUserSuccess">
	<evaluate expression="sendTicketGrantingTicketAction" />
	<transition to="serviceCheck" />
</action-state>