Spring Cloud Gateway(后文简称SCG)作为Spring生态新一代的应用网关,其作为整个应用集群的守门人,其除了可以负责路由转发,还可以将本来是属于各个服务的鉴权、限流、熔断等工作统一前置到应用网关统一进行配置管理,从而也减轻了后端接入服务的工作量,使后端服务更专注于核心业务逻辑的开发。同样SCG应用网关也可以将接入OAuth2的工作统一前置到网关中,SCG在OAuth2协议栈中:
如果接入OAuth2协议的Client端,如SPA(浏览器端)、Native App(移动app、桌面应用)具有独立完成OAuth2认证(如授权码模式)的能力(语言框架支持、开发人员可以配合修改代码),还是首选SCG仅作为Resource Server,即:
以上SCG作为Resource Server是比较理想的状态(职责划分清晰、SCG无状态),但需要Client端承担一定接入OAuth2的工作量,所以实际向各部门推行此套架构的时候,由于Client端接入OAuth2的门槛最终还是给落地带来了一些阻力,所以为了减轻Client端的接入复杂程度,也就需要SCG承担更多的工作,因此也就引出了本文的核心:SCG作为OAuth2 Client,即:
SCG作为OAuth2 Client的整体架构如下图:
注:
本文讲解的OAuth2接入皆是基于Spring Security OAuth2相关生态,
而Spring Security OAuth2也分为:
- Servlet Applications - 传统Spring Mvc开发(基于Tomcat、Undertow等容器)
- Reactive Applications - 基于响应式编程(Web Flux)
由于SCG建立在WebFlux上,所以相关集成说明参见:
https://docs.spring.io/spring-security/reference/reactive/oauth2/index.html
如上架构设计,即SCG作为OAuth2 Client的首要前提:
仅支持浏览器端应用(支持Cookie Storage、支持302重定向)
如SPA(VUE)、Web Template/Ajax应用等运行在浏览器中的应用,而像Native App(移动app、桌面应用)等无法直接使用Cookie Storage的应用形态,还是首选推荐SCG作为Resource Server的架构,也就是说SCG作为Resource Server的适用性更广(SPA、Native App、Web应用),而SCG作为OAuth2 Client仅适用于浏览器端应用(SPA、Web应用)。
在上述架构中,可以将应用分为3类:
第1类:前端应用 - 运行在浏览器端,向SCG发送请求
第2类:后端应用 - 由SCG代理的后端应用
第3类:应用网关SCG Client - 负责接入OAuth2授权、维护用户Session及Cookie、透传access_token到后端服务
前端应用与SCG Client间通过Session Cookie来保持用户状态,即由SCG Client完成OAuth2授权流程后,首先将授权信息(access_token、refresh_token、id_token、userInfo)写入到SCG端Session存储,然后再向浏览器端写Session Cookie,之后前端应用(同域、跨域)向SCG发送请求时,会携带SCG域名下的Session Cookie,SCG对Session Cookie认证通过后,可以将access_token透传到后端应用。
关于SPA请求SCG Client的完整认证过程参见如下顺序图:
如上图,SPA接入SCG Client,仅需统一处理Ajax调用返回的Http status 401 Unauthorized,
在收到401后,可统一重定向到SCG Client的自身的OAuth2 Authorization Endpoint:
https://scg-client/oauth2/authorization/{clientRegId}?redirect_uri=https://spa
同时可附加redirect_uri参数,此参数用于设置SPA自身的地址,
SCG Client会在完成OAuth2授权流程后再重定向回此参数指定的uri地址,即重定向回SPA,
此redirect_uri为扩展参数,后文有讲解,并非Spring Security OAuth2 Client原生支持的。
如果后端Api服务不想被任意访问,则SCG可为后端route配置TokenRelay过滤器,即将bearer access_token透传到后端的API服务,
而后端Api服务可以接入Spring Security OAuth2 Resource Server模块来完成对access_token的校验。
如果后端Api服务在部署在内网,不可被公开访问,亦可不开启TokenRelay过滤器,因为SCG Client已经完成过OAuth2登录,即已经认证过用户,所以后端Api服务可无需再对用户进行认证,若有获取用户信息的需要,可参见下面讲解中提到的自定义UserInfoRelay过滤器,将SCG Web Session的用户信息透传到后端Api服务即可。
Web Template/Ajax同时包含前端Html页面和后端Api服务,
关于SCG Client代理Web Template/Ajax服务的完整认证过程参见如下顺序图:
如上图,Web Template/Ajax接入SCG Client,
对于Html页面请求,SCG Client通过SaveRequest、302重定向机制自动完成OAuth2授权流程,
而对于Html页面中的发送后端服务的Ajax请求,仅需统一处理Ajax调用返回的Http status 401 Unauthorized,
在收到401后,可统一重定向到任一Html页面请求(如Homepage),请求Html页面即可由SCG Client自动完成OAuth2授权流程,
若后端服务有获取用户信息的需求,可自定义UserInfoRelay过滤器(实现逻辑参考TokenReplay),
SCG仅需将Web Session中的SPRING_SECURITY_CONTEXT用户信息透传到到后端服务,
而后端服务可根据需要读取x-user-info请求头获取用户信息(无需接入OAuth2 ResourceServer)。
由于SCG OAuth2 Client与浏览器端前端应用通过Session Cookie保存用户会话及登录态,所以为了保证SCG Client支持水平扩展,所以SCG需要使用分布式Session,如借助Redis存储统一管理Session,而不使用内存Session存储,避免前端请求路由到不同SCG部署实例后无法通过Cookie找到对应的内存Session。
在Spring生态中可以集成使用Spring Session,而Spring Session又分为HttpSession和WebSession等,而SCG(基于WebFlux)则需集成WebSession。
Spring Session支持的后端存储如下图:
实际集成时使用的Redis数据存储,具体集成Spring Session Redis Reactive配置application-session.yml如下:
spring:
# Redis配置
redis:
host: localhost
port: 6379
database: 0
#password:
client-type: lettuce
lettuce.pool:
enabled: true
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
#time-between-eviction-runs:
# Spring Session配置
session:
# session超时时间(默认30分钟,即1800s)
timeout: 600s
# 启动SpringSession Redis
store-type: redis
# SpringSession - Redis相关配置
redis:
# 清理失效session的cron表达式(默认每分钟)
cleanup-cron: 0 * * * * *
# session存储namespace(key前缀)
# 默认spring:session
namespace: scg:client
# 保存模式,即如何保存Session属性(默认on_set_attribute)
# on_set_attribute: 仅对调用过Session.setAttribute(String, Object)的属性进行保存
# on_get_attribute: 仅对调用过Session.setAttribute(String, Object)和Session.getAttribute(String)的属性进行保存
# always: 全量保存Session中的attributes
save-mode: on_set_attribute
# 刷新模式,即何时保存Session(默认on_save)
# on_save: 仅调用SessionRepository.save(Session)时保存Session,对应提交Response时
# immediate: 立即保存Session,对应SessionRepository#createSession()或者Session.setAttribute(String. Object)
flush-mode: on_save
# 配置活动(默认启用Keyspace events)
configure-action: notify_keyspace_events
server:
# webflux session cookie设置
reactive:
session:
cookie:
# 设置session Cookie名称
name: SCG_CLIENT_SESSION_ID
# http-only(禁止JS读取cookie)
http-only: true
# cookie过期时长(单位:秒)
# 设置同Spring Session timeout
# 默认不设置 空或者<0,即等同于session时效,即关闭浏览器时自动失效
#max-age: ${spring.session.timeout}
# =============== 支持Cookie Https且支持CORS ==================
# cookie secure使用https
secure: true
# cookie sameSite设置
same-site: none
实际开发时,除了上面提到的SCG Client需要支持分布式Session,而AuthServer(基于Spring Authorization Server实现 )也需要支持分布式Session,而AuthServevr基于Servlet容器实现,则需要集成HttpSession,基于HttpSession的配置文件如下:
# 之前的Spring.session配置与上面的配置一样,故省略
# 需要注意的是Cookie设置的前缀是不同的,
# WebSession Cookie配置前缀:server.reactive.session.cookie
# HttpSession Cookie配置前缀:server.servlet.session.cookie
server:
servlet:
# 设置session cookie相关
session:
cookie:
# 设置session Cookie名称
name: AUTH_SERVER_SESSION_ID
# http-only(禁止JS读取cookie)
http-only: true
# cookie过期时长(单位:秒)
# 设置同Spring Session timeout
# max-age: ${spring.session.timeout}
# 默认不设置 空或者<0,即等同于session时效,即关闭浏览器时自动失效
当前SCG Client检测到(SCG接收到请求时通过TokenRelayGatewayFilterFactory检测)当前用户的access_token还剩1分钟(默认值,可通过设置修改)就过期时,会触发SCG Client端执行Refresh Token授权流程,而此时若SCG Client端存储的refresh_token已经过期时,会导致AuthServer端返回Http status 400 invalid_grant,进而导致SCG返回Http status 500,具体代码实现参见RefreshTokenReactiveOAuth2AuthorizedClientProvider转换OAuth2AuthorizationException为ClientAuthorizationException导致返回
500,此处将ClientAuthorizationException转换为CredentialsExpiredException extends AuthenticationException,可以触发登录(避免返回500),具体SCG Client登录入口实现可参见后续讲解。
注:
具体的解决思路参见:https://github.com/spring-projects/spring-security/issues/11015
而GitHub上给出的是将ClientAuthorizationException转换为ClientAuthorizationRequiredException,
会直接302重定向到OAuth2 authorization_endpoint,而我在实际扩展时需要根据请求类型(Html、XHR)决定是重定向、还是返回401,所以最终转换为了CredentialsExpiredException extends AuthenticationException,触发进入SCG Client登录入口,而不是直接重定向到OAuth2 authorization_endpoint。同时由SCG域名跳转到AuthServer域名下authorization_endpoint还有跨域问题。
实际测试时发现Spring Security Reactive的登录入口不是很清晰,所以最后重写了登录入口规则,
即若发现用户未登录,则:
/**
* 自定义登录端点 - Ajax请求401,OPTIONS请求200,其他默认OAuth2 302 Redirect登录
*/
private ServerAuthenticationEntryPoint buildAjax401AndHtml302AuthEntryPoint() {
//请求头accept为application/json且忽略*/*
MediaTypeServerWebExchangeMatcher applicationJsonMatcher = new MediaTypeServerWebExchangeMatcher(MediaType.APPLICATION_JSON);
applicationJsonMatcher.setIgnoredMediaTypes(Stream.of(MediaType.ALL).collect(Collectors.toSet()));
List<DelegatingServerAuthenticationEntryPoint.DelegateEntry> delegateEntryList = Arrays.asList(
//请求头accept为application/json -> 返回401
new DelegatingServerAuthenticationEntryPoint.DelegateEntry(
applicationJsonMatcher,
new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)),
//请求头X-Requested-With为XMLHttpRequest -> 返回401
new DelegatingServerAuthenticationEntryPoint.DelegateEntry(new ServerWebExchangeMatcher() {
@Override
public Mono<MatchResult> matches(ServerWebExchange exchange) {
String xRequestedWith = exchange.getRequest().getHeaders().getFirst("X-Requested-With");
Boolean match = StringUtils.hasText(xRequestedWith) && xRequestedWith.equals("XMLHttpRequest");
return match ? MatchResult.match() : MatchResult.notMatch();
}
}, new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)),
//跨域OPTIONS请求返回200(解决浏览器报错)
new DelegatingServerAuthenticationEntryPoint.DelegateEntry(new ServerWebExchangeMatcher() {
@Override
public Mono<MatchResult> matches(ServerWebExchange exchange) {
HttpMethod method = exchange.getRequest().getMethod();
Boolean match = HttpMethod.OPTIONS.equals(method);
return match ? MatchResult.match() : MatchResult.notMatch();
}
}, new HttpStatusServerEntryPoint(HttpStatus.OK))
);
DelegatingServerAuthenticationEntryPoint nonAjaxLoginEntryPoint = new DelegatingServerAuthenticationEntryPoint(delegateEntryList);
//默认登录入口即为OAuth2重定向登录端点
nonAjaxLoginEntryPoint.setDefaultEntryPoint(new RedirectServerAuthenticationEntryPoint(this.oauth2LoginEndpoint));
return nonAjaxLoginEntryPoint;
}
为了支持SPA登录跳转(可参见前文SPA作为前端应用接入SCG Client的顺序图),
即SPA请求SCG OAuth2 Client登录端点/oauth2/authorization/{clientRegId},同时携带redirec_uri参数,SCG会缓存此redirect_uri,在SCG Client认证完成后再由SCG重定向回redirect_url所指向的SPA。设计是如此,但是Spring Security OAuth2 Client是不支持此参数的,所以就需要我们自己扩展实现。
找到如下切入点,即扩展实现SCG OAuth2 Client登录端点/oauth2/authorization/{clientRegId}的解析器:
具体实现就是扩展原DefaultServerOAuth2AuthorizationRequestResolver实现,提取redirect_uri参数,并模仿WebSessionServerRequestCache保存此redirect_uri到Session中,后续认证完成后Security框架就会自动重定向到此Session中的redirect_uri。
import com.luo.demo.scg.client.config.Oauth2ClientSecurityConfig;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.server.DefaultServerOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Optional;
/**
* OAuth2 Client Authorization Endpoint /oauth2/authoriztion/{clientRegId}
* 请求解析器扩展实现 - 支持提取query参数redirect_uri,用作后续OAuth2认证完成后SCG重定向到该指定redirect_uri。
* 适用场景:SPA -> SCG -> SCG返回401 -> SPA重定向到/oauth2/authoriztion/{clientRegId}?redirect_uri=http://spa -> SCG完成OAuth2认证后再重定向回http://spa
*
* @author luohq
* @date 2022-06-13
* @see DefaultServerOAuth2AuthorizationRequestResolver
* @see WebSessionServerRequestCache
* @see Oauth2ClientSecurityConfig
*/
public class SaveRequestServerOAuth2AuthorizationRequestResolver extends DefaultServerOAuth2AuthorizationRequestResolver {
private static final Log logger = LogFactory.getLog(SaveRequestServerOAuth2AuthorizationRequestResolver.class);
/**
* redirect uri参数名称
*/
private static final String PARAM_REDIRECT_URI = "redirect_uri";
/**
* WebSession对应的saveRequest属性名
* 完全沿用(兼容)WebSessionServerRequestCache定义
*/
private static final String DEFAULT_SAVED_REQUEST_ATTR = "SPRING_SECURITY_SAVED_REQUEST";
private String sessionAttrName = DEFAULT_SAVED_REQUEST_ATTR;
/**
* Creates a new instance
*
* @param clientRegistrationRepository the repository to resolve the
* {@link ClientRegistration}
*/
public SaveRequestServerOAuth2AuthorizationRequestResolver(
ReactiveClientRegistrationRepository clientRegistrationRepository) {
super(clientRegistrationRepository);
}
@Override
public Mono<OAuth2AuthorizationRequest> resolve(ServerWebExchange exchange) {
return super.resolve(exchange)
.doOnNext(oAuth2AuthorizationRequest -> {
//获取query参数redirect_uri
Optional.ofNullable(exchange.getRequest())
.map(ServerHttpRequest::getQueryParams)
.map(queryParams -> queryParams.get(PARAM_REDIRECT_URI))
.filter(redirectUris -> !CollectionUtils.isEmpty(redirectUris))
.map(redirectUris -> redirectUris.get(0))
.ifPresent(redirectUri -> {
//若redirect_uri非空,则覆盖Session中的SPRING_SECURITY_SAVED_REQUEST为redirect_uri
//即后续认证成功后可重定向回SPA指定页面
exchange.getSession().subscribe(webSession -> {
webSession.getAttributes().put(this.sessionAttrName, redirectUri);
logger.debug(LogMessage.format("SCG OAuth2 authorization endpoint queryParam redirect_uri added to WebSession: '%s'", redirectUri));
});
});
});
}
}
SCG Client在集成OidcClientInitiatedServerLogoutSuccessHandler实现OIDC登出时,需要提取SecurityContext中OIDC用户信息中的id_token,
GET end_session_point?id_token_hint={id_token_issued_to_client}&post_logout_redirect_uri={post_logout_redirect_uri}
实际测试发现若执行过Refresh Token流程,虽然SCG Client端存储(OAuth2AuthorizedClient)的access_token和refresh_token都已被动态更新,但是SecurityContext中OIDC用户信息中的id_token还是最初始登录时获取的值,在执行Refresh Token后没有被同步更新,进而导致执行OIDC登出时携带的id_token不是最新的,导致AuthServer返回异常(AuthServer扩展实现返回Http Status 400),所以为了解决这个问题就需要在SCG Client刷新Token后同步更新SecurityContext中OIDC用户信息中的id_token。
SCG Client刷新Token的具体调用链如下:
最终定位到RefreshTokenReactiveOAuth2AuthorizedClientProvider,扩展实现如下:
Web Template/Ajax应用是通过SCG代理,所以请求Web Template/Ajax应用(Html页面、Ajax请求)也是直接请求SCG,然后由SCG路由到对应的Web Template/Ajax应用,即Web Template/Ajax应用是和SCG享有共同的域名的,如:
请求html页面:http://scg-client/webAjax/page
Html页面发送Ajax请求:http://scg-client/webAjax/svc
所以Web Template/Ajax应用和SCG不存在跨域的问题。
SPA可以部署到Nginx中,然后由Nginx路由到SCG Client,如:
请求SPA:http://nginx/spa
SPA向SCG网关发送Ajax请求:http://nginx/scg/resource1
此时SPA和SCG也不存在跨域问题。
而SPA部署在不同域名下(与SCG域名不同),又由于前端应用SPA和SCG间通过Session Cookie保持用户会话及登录态,此时就需要考虑跨域Session Cookie传输的问题了。如SCG Client认证完成后会向浏览器写SCG域名下(Domain=scg-client)的Session Cookie:
Set-Cookie: SCG_CLIENT_SESSION_ID=5f59d21a-31b0-4ab7-b150-59c65a17d5e6; Domain=scg-client,Path=/; HttpOnly;
而SPA部署在不同于SCG的域名下:
http://spa
此时由SPA向SCG发送Ajax请求,即存在跨域及跨域Session Cookie传输的问题,即SPA向SCG发送Ajax请求时默认是无法携带SCG域名下下的Cookie的,导致SCG检测不到Cookie而请求认证失败。若想实现跨域(SPA携带SCG Cookie)Session Cookie的传输,最终解决方案如下:
1)SCG Session Cookie支持Https及跨域传输:
Set-Cookie: SCG_CLIENT_SESSION_ID=5f59d21a-31b0-4ab7-b150-59c65a17d5e6; Path=/;
Secure; HttpOnly; SameSite=None
注: 如此也即要求SPA和SCG均使用HTTPS协议
2)SCG(服务端)支持SPA域名及Cookie跨域:
注:setAllowCredentials为true时,allowedOrigin不能为*,即需明确设置allowOrigin对应域名
3)SPA(客户端)Ajax设置withCredentials=true(即支持跨域Cookie传输):
示例代码参见:https://gitee.com/luoex/oauth2-auth-server-oidc/tree/main/scg-client-prod-sample
参考:
Spring Session
https://spring.io/projects/spring-session
https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.spring-session
SCG & Spring Security OAuth2
https://github.com/spring-projects/spring-security/issues/11015
CORS Cookie
https://geekflare.com/enable-cors-httponly-cookie-secure-token/
Reactor
https://stackoverflow.com/questions/51315378/reactivesecuritycontextholder-getcontext-is-empty-but-authenticationprincipal
https://www.tabnine.com/code/java/classes/org.springframework.security.core.context.ReactiveSecurityContextHolder
https://www.tabnine.com/code/java/methods/reactor.util.context.Context/get