前言:最近在学习搭建oauth2协议的开放平台,把搭建框架时的思路以及遇到的问题记录下来。
文章会持续更新,前期可能会比较零碎,最后会整合一起,写一篇从部署到使用、踩坑、依赖版本解决等完整文章。
使用的是Spring Security Oauth2的新版框架,官网地址:点此跳转
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
下面分享开始,今天先记录下授权服务器的授权过程:
授权服务器的授权过程
在授权服务器中会定义两个filterChain过滤器链,一个是系统的认证定制,一个是授权服务器的认证定制。
- 授权服务器的定制:如官方文档一样,声明了未从授权端点进行身份验证时重定向到登录页面
- 原系统的认证授权定制:加了一些基础的系统定制,如登录失败处理器、鉴权、端点白名单、跨域、访问拒绝处理器等
// 授权服务器的认证授权定制
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
// 配置当前 FilterChain 只会拦截oauth2相关的请求
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http
// Redirect to the login page when not authenticated from the
// authorization endpoint
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(
new LoginUrlAuthenticationEntryPoint("/login"))
)
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
// 原系统的认证授权定制
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf()
.disable()
.cors()
.configurationSource(corsConfigurationSource);
http
.exceptionHandling()
.accessDeniedHandler(new DefaultAccessDeniedHandler());
http
.logout()
.clearAuthentication(true)
.logoutUrl(SecurityConstants.AUTH_LOGOUT)
.logoutSuccessHandler(new DefaultLogoutSuccessHandler());
http
// Form login handles the redirect to the login page from the
// authorization server filter chain
.formLogin()
.loginProcessingUrl(SecurityConstants.LOGIN_PROCESSING_URL)
.failureHandler(new DefaultAuthenticationFailureHandler())
// .successHandler(new DefaultAuthenticationSuccessHandler())
;
http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
authorizationManagerRequestMatcherRegistry
.antMatchers(SecurityConstants.URL_IMAGE_CAPTCHA, "/oauth2/**", "/login/**")
.permitAll()
.anyRequest()
.access(defaultSecurityExpressionRoot);
});
return http.build();
}
三方客户端拉起授权码模式的授权流程:
- 点击授权链接发起请求(包含颁发的三方客户端信息:key、secret)
- 携带用户在开放平台系统(例如微信授权,则需要在微信系统中先登录)所需的token作为header参数
- 如果授权服务器的sessionFilter未发现有开放平台系统的授权信息(token),则会重定向到开放平台系统的登录页面
- 登录成功后重定向到开放平台系统的授权页(如配置无需确认授权,则会跳过该流程)
- 用户确认授权后再次重定向到三方客户端在开放平台配置的重定向地址,并且带上授权服务器的code参数
这里有点绕,给大家画个图帮助下理解
授权码模式流程图
如果有微信h5、微信小程序等开放平台对接经验的小伙伴应该很容易理解。
模拟流程:
- 第一步把系统中暂时写死的三方客户端ID和secret拼接好(重定向地址先写百度官网),模拟下授权请求
http://127.0.0.1:8080/oauth2/authorize?response_type=code&client_id=messaging-client&client_secret=secret&redirect_uri=https://www.baidu.com
可以看到链接302重定向到了login页面,该页面是Oauth2的默认登录页面
- 由于第一步是直接申请授权,并无开放平台的token信息,来看下sessionFilter是怎么处理的:
我这里是在security中定制了sessionId的形式,使用header中的x-auth-token作为主要的session获取方式,cookie作为辅助使用
@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
HeaderHttpSessionIdResolver primaryHttpSessionIdResolver = HeaderHttpSessionIdResolver.xAuthToken();
CookieHttpSessionIdResolver cookieHttpSessionIdResolver = new CookieHttpSessionIdResolver();
return new CompositeHttpSessionIdResolver(primaryHttpSessionIdResolver, Arrays.asList(primaryHttpSessionIdResolver, cookieHttpSessionIdResolver));
}
public final class CompositeHttpSessionIdResolver implements HttpSessionIdResolver {
private final HttpSessionIdResolver primaryHttpSessionIdResolver;
private final List<HttpSessionIdResolver> httpSessionIdResolvers;
public CompositeHttpSessionIdResolver(HttpSessionIdResolver primaryHttpSessionIdResolver, List<HttpSessionIdResolver> httpSessionIdResolvers) {
this.primaryHttpSessionIdResolver = primaryHttpSessionIdResolver;
this.httpSessionIdResolvers = httpSessionIdResolvers;
}
}
HttpSessionSecurityContextRepository,会先校验下是否有当前session对应的已成功认证的上下文信息。
源码对应:**HttpSessionSecurityContextRepository.loadContext()**方法
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);
SecurityContext context = readSecurityContextFromSession(httpSession);
if (context == null) {
context = generateNewContext();
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Created %s", context));
}
}
if (response != null) {
SaveToSessionResponseWrapper wrappedResponse = new SaveToSessionResponseWrapper(response, request,
httpSession != null, context);
requestResponseHolder.setResponse(wrappedResponse);
requestResponseHolder.setRequest(new SaveToSessionRequestWrapper(request, wrappedResponse));
}
return context;
}
getSession中源码:
getSession方法会调用getRequestedSession方法来获取session
SessionRepositoryFilter.getSession().getRequestedSession()
private S getRequestedSession() {
if (!this.requestedSessionCached) {
List<String> sessionIds = SessionRepositoryFilter.this.httpSessionIdResolver.resolveSessionIds(this);
for (String sessionId : sessionIds) {
if (this.requestedSessionId == null) {
this.requestedSessionId = sessionId;
}
S session = SessionRepositoryFilter.this.sessionRepository.findById(sessionId);
if (session != null) {
this.requestedSession = session;
this.requestedSessionId = sessionId;
break;
}
}
this.requestedSessionCached = true;
}
return this.requestedSession;
}
这里可以看到当找到sessionID后会经sessionRepository属性来获取session的值,对于这里我也做了定制化,使用了RessionSessionRepository,这样做的原因是为了以后分布式部署和集群下的session信息共享
@Bean
@ConditionalOnBean(RedissonSessionRepository.class)
public SessionRegistry sessionRegistry(RedissonSessionRepository sessionRepository,
@Value("${spring.application.name}") String applicationName) {
if (StrUtil.isNotBlank(applicationName)) {
sessionRepository.setKeyPrefix(buildKeyPrefix(applicationName) + ":session:");
}
return new SpringSessionBackedSessionRegistry<>(sessionRepository);
}
获取session后会校验readSecurityContextFromSession,发现是无认证信息的,会被FilterSecurityInterceptor拦截器抛出异常,源码地址在:
FilterSecurityInterceptor.invoke方法的super.beforeInvocation(filterInvocation);
-
过滤器链继续向下执行,经ExceptionTranslationFilter捕获到异常handleSpringSecurityException方法中重定向到配置的默认登录地址:http://127.0.0.1:8080/login
-
在登录页面填写暂时写死的平台账号进行登录,再次经过HttpSessionSecurityContextRepository.loadContext方法,不过依旧是未认证,经定制UsernamePasswordAuthenticationFilter认证成功后,被认证成功处理器AbstractAuthenticationProcessingFilter.onAuthenticationSuccess保存session和执行重定向操作。此时重定向地址为:
http://127.0.0.1:8080/oauth2/authorize?response_type=code&client_id=messaging-client&client_secret=secret&redirect_uri=https://www.baidu.com
。此时因已经对sessionID做了save操作,会找到sessionID对应的认证信息。继续向下操作。 -
经OAuth2AuthorizationEndpointFilter过滤器,并且会在OAuth2AuthorizationCodeRequestAuthenticationProvider.validate方法中校验redirectUri与系统中暂时写死的是否一致,authenticate方法通过后,再次重定向到指定uri,并且携带code参数