用户服务之实现单点登录

目前已经完成了用户服务V1.0的开发,类似于QQ,微信,微博等第三方登录,HbnnMall用户服务也是一个第三方登录系统。所有业务系统都可以实现接入。当然,目前就是我自己写着玩呢。

本文主要说一下我自己的实现以及Spring-security的原理。

本次实现的功能包括:

1、支持业务系统接入实现第三方登录;

2、登录方式包括手机验证码登录以及用户名密码登录;

3、token的申请和生成;

4、通过token解析用户信息,并访问受限资源;

5、token的刷新。

6、Python SDK.并在Django中进行实践完成单点登录。(后续会写JAVA,golangSDK,其实很简单,就是对HTTP的封装)

先说下业务Web网站的第三方登录流程:

1、首先判断请求Cookie是否有token,如果没有走第三步,有就走第二步;

2、如果有,请求用户服务校验token有效性接口。校验不通过走第三步;

3、利用用户服务分配的客户端id和秘钥,带上回调地址,我采用的OAUTH2授权码模式,请求认证服务器分配授权码;

4、如果未登录,用户服务器会自动跳转至登录界面。如下图:

5、登录成功后,会跳转至回调地址并带有code;

类似:http://127.0.0.1:8000/account/complete/?code=zCQtSz&state=xyz

6、利用code,发起请求获取token;

拿到code之后,就是业务系统需要做的事了,向用户主动发起。当然这完全可以通过SDK实现。Python一般有比较成熟的Django库,但都是对接的QQ,微信等。我自己的用户服务也是一个类似第三方服务。我不是在上面实现的。是自己又写了一个。其实就是rediect到自己的回调地址之后,实现处理逻辑。

下面是一个简单的逻辑。token是通过我写的SDK获取的。未来,我会实现Java和PHP版本的SDK。

@never_cache
@csrf_exempt
def complete(request):
    url = request.get_raw_uri()
    code = re.findall(r"\?code=(.+?)&state=", url)[0]
    logger.info("get code :" + code[0])
    token = sdk.getToken(code)
    session = request.session
    response = HttpResponseRedirect(session.get("redirect_uri"))
    del session["redirect_uri"]
    logger.info("get token:" + token)
    response.set_cookie("serviceToken", token)
    return response

7、成功获取token后,通过token获取用户信息。

其实上面已经说清楚了OAUTH2协议的过程,授权类型采用的是授权码类型,也是最流行,最完整的授权方式,当然后续用户服务需要实用https协议。至于其他授权类型可以看下面参考资料的协议原理详解。

Spring Security的入侵方式是通过加入到FilterChain实现的,通过不同的Filter来达到响应的权限认证等操作。网上一个哥们画的图不错,参考资料也有他的文章。

其执行时序图类似如下图:

只要我们在配置中加入了继承自WebSecurityConfigurerAdapter的Bean,系统就会将Spring Security的Filter加入到ApplicationFilterChain中。

来看一个FIlter的初始化过程:

看下源码:

容器启动之后SpringServletContainerInitializer会遍历调用所有继承WebApplicationInitializer的类的onStartUp,看一下SpringSecurity的实现类:

public abstract class AbstractSecurityWebApplicationInitializer
		implements WebApplicationInitializer {

public final void onStartup(ServletContext servletContext) {
		beforeSpringSecurityFilterChain(servletContext);
		if (this.configurationClasses != null) {
			AnnotationConfigWebApplicationContext rootAppContext = new AnnotationConfigWebApplicationContext();
			rootAppContext.register(this.configurationClasses);
			servletContext.addListener(new ContextLoaderListener(rootAppContext));
		}
		if (enableHttpSessionEventPublisher()) {
			servletContext.addListener(
					"org.springframework.security.web.session.HttpSessionEventPublisher");
		}
		servletContext.setSessionTrackingModes(getSessionTrackingModes());
		insertSpringSecurityFilterChain(servletContext);
		afterSpringSecurityFilterChain(servletContext);
	}

}

最重要的就是insertSpringSecurityFilterChain方法:


class AbstractSecurityWebApplicationInitializer{
private void insertSpringSecurityFilterChain(ServletContext servletContext) {
		String filterName = DEFAULT_FILTER_NAME;
        //创建了一个代理,获取spring securityFilterChain,实际上就是下面的FilterProxy
		DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(
				filterName);
		String contextAttribute = getWebApplicationContextAttribute();
		if (contextAttribute != null) {
			springSecurityFilterChain.setContextAttribute(contextAttribute);
		}
       //注册filter
		registerFilter(servletContext, true, filterName, springSecurityFilterChain);
	}


private void registerFilter(ServletContext servletContext,
								boolean insertBeforeOtherFilters, String filterName, Filter filter) {
		Dynamic registration = servletContext.addFilter(filterName, filter);
		if (registration == null) {
			throw new IllegalStateException(
					"Duplicate Filter registration for '" + filterName
							+ "'. Check to ensure the Filter is only configured once.");
		}
		registration.setAsyncSupported(isAsyncSecuritySupported());
		EnumSet<DispatcherType> dispatcherTypes = getSecurityDispatcherTypes();
		registration.addMappingForUrlPatterns(dispatcherTypes, !insertBeforeOtherFilters,
				"/*");
	}

再看下WebConfiguration类。看下下面代码:

	@Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
	public Filter springSecurityFilterChain() throws Exception {
		boolean hasConfigurers = webSecurityConfigurers != null
				&& !webSecurityConfigurers.isEmpty();
		if (!hasConfigurers) {
			WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
					.postProcess(new WebSecurityConfigurerAdapter() {
					});
			webSecurity.apply(adapter);
		}
		return webSecurity.build();
	}

上述代码创建了一个Spring Security Filter Chain。这个chain实际上是一个FilterChainProxy,是内部所有filter的代理。

其实到上面已经非常清晰了,通过上面的操作,SpringSecurity的FilterChain代理成功加入到了Servlet中。每次有新请求到来时都会走Spring security的filter.

对于Filter,Spring Security完全支持自定义,可以自己手写Filter实现,然后在Security的配置中加入即可。我也写了一个,主要是防止session过期之后或者不可用session时,只有token就可以获取资源,不用再走认证程序。

@Slf4j
@Component
public class HbnnTokenFilter extends OncePerRequestFilter {

    @Resource
    HbnnTokenService tokenService;

    @Autowired
    @Qualifier("hbnnUserDetailService")
    UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        log.info("start to enter into hbnn token filter,cur spring security context:{}",SecurityContextHolder.getContext().getAuthentication());
        try{
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            String token = HttpUtil.getCookie(HttpKit.getRequest(), Const.TOKEN_KEY);
            if((token != null && !token.isEmpty()) && (authentication == null || !authentication.isAuthenticated())){
                Long uid = tokenService.getUid(token);
                if(uid != null && uid > 0){
                    UserDetails userDetails = userDetailsService.loadUserByUsername(uid.toString());
                    if(userDetails != null){
                        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                                userDetails,null,userDetails.getAuthorities()
                        );
                        usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                        log.info("authencation by serviceToken.the authencation:{}",usernamePasswordAuthenticationToken);
                    }
                }
            }
        }catch (Exception e){
            log.error("get token from cookie error:",e);
        }
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }
}

再看下Spring-Security-OAUTH。spring-security-oauth可以同时构建认证服务器和资源服务器。对于OAUTH2协议有了解的应该都知道这两个概念了。

当我们访问/oauth/authorize(通过AuthorizationEndpoint实现时)时,spring-security-oauth会引导请求进入Spring-security的filter chain。如果认证成功了,才会真正处理请求。整个过程也都是通过其实现的OAuth2ClientContextFilter开始的。如果通过之后AuthorizationEndpoint会做具体的处理,如果授权模式是响应码,就会返回响应码。具体的实现逻辑这里就不说了。

响应授权码拿到之后,需要使用code申请token。具体的point也是Spring-security-oauth实现的,实现类是TokenEndPoint。请求的地址是 /outh/token,此外该路径也接受token的刷新操作。

tokenService Bean可以自定义,我实现的一个:

    @Bean
    @Primary
    public DefaultTokenServices tokenServices(){
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(new RedisTokenStore(redisConnectionFactory));
        defaultTokenServices.setTokenEnhancer(tokenEnhancer);
        defaultTokenServices.setAuthenticationManager(authenticationManager);
        return defaultTokenServices;
    }

上面主要是要传入自己希望使用哪种方式存储token,以及token该如何实现如何生成。tokenEnhancer为自己实现的类。直接提出token的生成源码,这个地方主要是参照小米的passport实现的。使用AES128算法加密以及Base64编码。

 @Override
    public String createToken(TokenClass tokenClass) {
        if(tokenClass == null){
            throw new BussinessException(BizExceptionEnum.TOKEN_REQUEST_UID);
        }

        Gson gson = new Gson();
        //TODO 目前sid 和 key都是一个,后续每个业务系统申请一个。但是我用不到,没有那么多不同的部门。 key为base64加密
        TokenHelper tokenHelper = new TokenHelper(tokenClass.getSid(),AESKEY);
        log.info("token json:{}",gson.toJson(tokenClass));
        String encryptContent = tokenHelper.encryptBySvcKeyWithIV(gson.toJson(tokenClass));
        String sign = encryptContent;
        try{
             sign = tokenHelper.sha1HMAC(encryptContent);
        }catch (Exception e){
            log.error("生成sign出错:",e);
            throw new BussinessException(BizExceptionEnum.TOKEN_CREATE_ERROR);
        }
        tokenClass.setSign(sign);
        return String.format("%s&%s&%s",  tokenClass.getSid(), encryptContent,sign);
    }

参考资料:

Spring Security 源码分析八:Spring Security 过滤链一 - 概念与设计

由浅入深理解SpringSecurityOauth2框架原理

Spring Security实战干货

理解OAuth 2.0

OAuth2.0协议原理详解

OAuth2.0 协议原理

Spring security oauth2认证流程分析

--------EOF---------
本文微信分享/扫码阅读