近日学习企业微信开发,需要使用其授权登录功能,看了一下API,企业微信授权登录事实上就是一种OAuth2登录,Spring Security 5已经支持OAuth登录。照文档写了Github授权登录的测试代码,虽然因为对OAuth2的概念不是很熟悉而走了些弯路,但回头来看,用Spring Security 5实现OAuth登录事实上不算太难。但是微信授权登录和微信公众号的其他功能接口相差不大,即便利用Spring Security实现了OAuth登录,其他需要的功能仍然必须去实现,而如果实现了微信公众号的大多数API接口,那么授权登录无非是调用用户信息接口然后把生成一个Prinpical保存在会话里而已,利用Spring Security的OAuth登录的意义就不大了。事实上,使用Spring Security OAuth登录虽然不难但还是挺绕的,更关键的是企业微信的用户授权登录跟Github授权登录还不一样,并不是标准的OAuth登录,用利用Spring Security OAuth登录来实现需要自定义的东西很多,还不如直接实现微信API,在实现Spring Security的自定义登录。

因此又再一次梳理了一下Spring Security的用户认证与网页登录功能,前段时间写的《理解Spring Web Security实现Ajax登录》里面分析过,整个模块设计得还是很必要和合理的,考虑得很全面,但正因为考虑得很全面了所以如果要扩展和自定义,就比较绕,大概这就是Spring的不好之处了吧。但不管怎样,Spring灵活的安全配置,对于想法经常改变,但又怕改代码的人来说,还是比较有诱惑力的,还是决定跟着绕一绕。

一般来说,web用户认证流程如下:

  1. 用户访问网站,点击一个链接;
  2. 请求发送到服务器,服务器检查是否是一个保护的资源;
  3. 如果用户没有认证,服务器回传一个内容提示必须认证,可能是一段代码或者重定向到一个页面;
  4. 依赖认证机制,浏览器或者重定向到一个登录而或者发送身份信息(例如BASIC认证对话框、cookie、X.509认证等等);
  5. 浏览器回传包含身份信息到服务器,通过Http头部或表单;
  6. 服务器校验用户身份,如果校验通过则继续,如校验不通过,通常浏览器会重试一次,跳到第2步;
  7. 如果用户已经有一个身份,但是仍然没有权限,服务器通常我返回一个403错误。

过程很简单,但是在实际项目中,哪些资源需要哪些权限才能访问是灵活的,千变万化的。应该要能灵活配置,这一点Spring Security做得很好,然后就是用户登录与验证,用户登录就是构造用户信息在Session中,验证就是检查用户凭证并且填充用户身份信息,用户登录构造用户信息以及验证用户凭证并授予权限其实可以在一个controller里实现,但是设计得好的系统在filter里实现,因为MVC的C主要是实现业务逻辑的,把用户认证放在filter里使得控制器的用途更明确,代码结构更加清晰。现在基本上所有的框架都是这样做的。在Spring里,前述第3步叫认证入口点AuthenticationEntryPoint,常用的入口有BasicAuthenticationEntryPoint、LoginUrlAuthenticationEntryPoint等,取决于配置的认证方式,不同的认证方式入口不同,有可能发是出一个登录对话框,有可能是重定向到一个登录页等等,Spring还定义了一个委托认证入口点DelegatingAuthenticationEntryPoint,可以根据http请求头信息来委托不同的入口,例如:如果用户是使用企业微信访问就跳转到一个特殊的地址采用特殊的登录方式,否则跳转到登录页采用表单登录方式。

public DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint() {
	LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<RequestMatcher, AuthenticationEntryPoint>();
	entryPoints.put(new UseragentContainRequestMatcher("wxwork"),new LoginUrlAuthenticationEntryPoint("/oauth2/authentication/wxwork"));
	DelegatingAuthenticationEntryPoint dae = new DelegatingAuthenticationEntryPoint(entryPoints);
	dae.setDefaultEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
	return dae;
}

而针对特殊的认证入口,通过自定义过滤器获取用户信息并保存在会话中就可完成自定义的认证而不需要通过登录页填写用户名密码。

对于企业微信授权登录来说,是不需要用户输入用户口密码的,至少不在当前应用里输入。所以Basic认证、Form认证都不适用,需要自定义。很多实现自定义认证的代码在用户凭证这里通常使用的是UsernamePasswordAuthenticationToken,当然,因为认证是自己实现,用这个Token也没什么不可以,虽然这个类包含了用户名密码,但是不是认证通过仅仅是看isAuthenticated()的返回值是否为真。用户名密码匹不匹配并没多大关系,UsernamePasswordAuthenticationToken是继承自AbstractAuthenticationToken类的,自己实现一个类很简单,所以还是自己实现一个意义更明确一些。 然后最主要的就是写一个过滤器实现用户授权的功能。从UsernamePasswordAuthenticationFilter->AbstractAuthenticationProcessingFilter->GenericFilterBean->javax.servlet.Filter继承关系来看,继承AbstractAuthenticationProcessingFilter比较好,但是AbstractAuthenticationProcessingFilter这个类会检查其成员,其中的AuthenticationManager必须被定义,也就是哪怕不使用AuthenticationManager都必须定义一个,如果不想去实现AuthenticationManager类,就只有从GenericFilterBean继承,从GenericFilterBean继承要实现的代码并不多,主要仍然是doFilter方法,然后认证用户后把Token通过SecurityContextHolder.getContext().setAuthentication()方法保存即可。关键是这个方法,通过控制器实现自定义认证也要用到这个方法。一般自定义认证过滤器不直接实现javax.servlet.Filter接口,因为如果这样,使用Spring容器管理的Bean就会很麻烦。当然,GenericFilterBean的OncePerRequestFilter子类也是可以使用的,只是意义不大,没有必要。

在Spring中,AuthenticationManager只是一个接口,这个接口只有一个方法就是authenticate,也就是说这个接口的实现类就是用来实现具体的认证的。固然可以在过滤器的doFilter方法里直接实现认证,但分开来写同样是结构化程序设计的规范,同样使代码结构清楚,所以实现AuthenticationManager然后从AbstractAuthenticationProcessingFilter继承实现认证过滤器是正确的做法。而Spring已经有一个AuthenticationManager接口的实现叫ProviderManager,这个ProviderManager包含一个AuthenticationProvider类型的列表,真正的用户认证过程由列表中的AuthenticationProvider具体实现。而AuthenticationProvider又是一个接口,有两个方法一个是authenticate,另一个是supports,ProviderManager通过调用列表中的AuthenticationProvider的supports方法来查找支持的AuthenticationProvider,然后调用AuthenticationProvider的authenticate方法来进行真真正正的用户认证。这一部分非常绕,本来直接写一小段程序就完事的事,需要实现这个实现那个,定义这个定义那个,非常麻烦,但是很明显,绕这么多地方,可以自定义的地方是很多的,虽然看上去很简单的一个过程,都留下了很多可能,所以说Spring还是很灵活的,就是前提得跟着绕,否则需求一变,很多代码就得重写,用Spring就不至于全部重写。但是跟着绕的过程也不容易,中间的得失还真不好说,如果从学习的角度看,那绝对是值得的。

理清楚之后就是写代码了,主要就是几个类,除了一个继承至AbstractAuthenticationProcessingFilter的过滤器之外,还要有一个继承至AuthenticationProvider的认证提供者类和一个继承自AbstractAuthenticationToken的类,因为从AuthenticationProvider类的supports(Class<?> authentication)方法签名来看,不同的认证提供者支持不同的Token类型,所以还得实现Token的类。其他就是配置,需要在Configuration里定义过滤器Bean,但是过滤器Bean又需要一个AuthenticationManager,所以还得选定义一个AuthenticationManager Bean,这个Bean在初始化时把自定义的AuthenticationProvider添加到其列表中。最后是在configure(HttpSecurity http)里面.addFilterBefore()把过滤器Bean加入到springSecurityFilterChain,其实不加入springSecurityFilterChain也可以,但是就得在WebApplicationInitializer里面添加自定义的过滤器和springSecurityFilterChain了。如果要定制认证入口,除了DelegatingAuthenticationEntryPoint Bean外,还要在configure(HttpSecurity http)里面.exceptionHandling().authenticationEntryPoint(this.delegatingAuthenticationEntryPoint())定义入口。