用户服务之实现单点登录
目前已经完成了用户服务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框架原理
微信分享/微信扫码阅读