在年初的时候我参与了一个项目,当时是很多家公司协同完成这个项目,其中一个公司专门负责登录这块的内容,需要我们的后端接入他们的单点登录(OAuth2 授权码模式),这块接入工作是由我来负责,我们的项目是微服务架构,经过网上各种查阅资料发现网关作为OAuth2 Client接入单点登录,将用户信息解析传递给下游微服务是最佳方案,在本文中我将详细讲解怎么基于Spring Cloud Gateway 接入第三方单点登录。
- 如文章中有明显错误或者用词不当的地方,欢迎大家在评论区批评指正,我看到后会及时修改。
- 如想要和博主进行技术栈方面的讨论和交流可私信我。
目录
4.1.4. 重写DefaultServerOAuth2AuthorizationRequestResolver
4.1.8. 编写ReactiveOAuth2UserService实现类
1. 前言
Spring Cloud Gateway是Spring Cloud生态系统中的一个组件,主要用于构建微服务架构中的网关服务。它提供了一种灵活而强大的方式来路由请求、过滤请求以及添加各种功能,如负载均衡、熔断、安全性等。通过将Spring Cloud Gateway作为OAuth2 Client,可以实现用户在系统中的统一认证体验。用户只需要一次登录,即可访问多个微服务,避免了在每个服务中都进行独立的认证,下游微服务只需要专注自己的业务代码即可。
2. 流程图
让我们来先看一下基于网关集成单点登录的流程图(OAuth2授权码模式),我这边只是一个大致流程,想要看完整细致流程的同学可以去看一下大佬写的这篇文章:Spring Cloud Gateway作为OAuth2 Client_oauth2客户端接口为什么跳转到login_罗小爬EX的博客-CSDN博客
3. 开发环境搭建
3.1. 项目结构
基于Spring Cloud Gateway作为OAuth2 Client接入单点登录的项目结构如下图所示:
由上图可以看出这个项目(demo)是微服务组织架构,这里我只创建了两个moudle(父模块不算)即网关和资源服务器。
3.2. 所用版本工具
依赖 | 版本 |
---|---|
Spring Boot | 2.6.3 |
Spring Cloud Alibaba | 2021.0.1.0 |
Spring Cloud | 2021.0.1 |
java | 1.8 |
redis | 6.2 |
3.3. pom依赖
1. 父模块依赖
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>2021.0.1</spring-cloud.version>
<cloud-alibaba.version>2021.0.1.0</cloud-alibaba.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- springCloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
2. 网关模块依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
</dependencies>
3. 资源服务器模块依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
</dependencies>
4. 核心代码
4.1. 网关模块核心代码
4.1.1. 编写网关yml配置
server:
reactive:
session:
cookie:
http-only: true
port: 8888
system:
whiteList:
- "/auth"
- "/oauth2"
- "/favicon.ico"
- "/login"
spring:
cloud:
gateway:
routes:
- id: geoscene-back-resource
uri: http://127.0.0.1:8090
predicates:
- Path=/resource/**
filters:
- TokenRelay
- UserInfoRelay
session:
store-type: redis # 会话存储类型
redis:
cleanup-cron: 0 * * * * *
flush-mode: on_save # 会话刷新模式
namespace: gateway:session # 用于存储会话的键的命名空间
save-mode: on_set_attribute
redis:
host: localhost
port: 6379
# password: 123456
security:
filter:
order: 5
oauth2:
client:
registration:
gas:
provider: gas
client-id: 在第三方授权中心获取的 client-id
client-secret: 在第三方授权中心获取(自定义)的 client-secret
redirect-uri: http://127.0.0.1:8888/login/oauth2/code/gas
authorization-grant-type: authorization_code
client-authentication-method: client_secret_basic
scope: userinfo
provider:
gas:
issuer-uri: 填写第三方认证地址
#
logging:
level:
root: INFO
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.security.oauth2: INFO
org.springframework.cloud.gateway: INFO
4.1.2. 编写Security授权配置主文件
@Configuration(proxyBeanMethods = false)
@EnableWebFluxSecurity
public class Oauth2ClientSecurityConfig {
private String oauth2LoginEndpoint = "/login/oauth2/code/gas";
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, ServerOAuth2AuthorizationRequestResolver saveRequestServerOAuth2AuthorizationRequestResolver) {
http
.authorizeExchange(authorize -> authorize
.pathMatchers("/auth/**", "/oauth2/**"
).permitAll()
.anyExchange().authenticated()
)
.oauth2Login(oauth2Login -> oauth2Login
// 发起 OAuth2 登录的地址(服务端)
.authorizationRequestResolver(saveRequestServerOAuth2AuthorizationRequestResolver)
// OAuth2 外部用户登录授权后的跳转地址(服务端)
.authenticationMatcher(new PathPatternParserServerWebExchangeMatcher(
oauth2LoginEndpoint))
)
.cors().disable();
return http.build();
}
/**
* OAuth2 Client Authorization Endpoint /oauth2/authoriztion/{clientRegId}
* 请求解析器扩展实现 - 支持提取query参数redirect_uri,用作后续OAuth2认证完成后网关重定向到该指定redirect_uri。
* 适用场景:前端应用 -> 网关 -> 网关返回401 -> 前端应用重定向到/oauth2/authorization/{clientRegId}?redirect_uri=http://登录后界面 -> 网关完成OAuth2认证后再重定向回http://登录后界面
*/
@Bean
@Primary
public ServerOAuth2AuthorizationRequestResolver saveRequestServerOAuth2AuthorizationRequestResolver(ReactiveClientRegistrationRepository clientRegistrationRepository) {
return new SaveRequestServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository);
}
/**
* 自定义UserInfo过滤器工厂
*/
@Bean
public UserInfoRelayGatewayFilterFactory userInfoRelayGatewayFilterFactory() {
return new UserInfoRelayGatewayFilterFactory();
}
}
4.1.3. 编写认证过滤器
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class CustomWebFilter implements WebFilter {
@Autowired
private UrlConfig urlConfig;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// 请求对象
ServerHttpRequest request = exchange.getRequest();
// 响应对象
ServerHttpResponse response = exchange.getResponse();
return exchange.getSession().flatMap(webSession -> {
for (int i = 0; i <urlConfig.getWhiteList().size() ; i++) {
if (request.getURI().getPath().contains(urlConfig.getWhiteList().get(i))) {
return chain.filter(exchange);
}
}
if( webSession.getAttribute("SPRING_SECURITY_CONTEXT")==null||!((SecurityContext)webSession.getAttribute("SPRING_SECURITY_CONTEXT")).getAuthentication().isAuthenticated()){
JSONObject message = new JSONObject();
message.put("code", 401);
message.put("status","fail");
message.put("message", "缺少身份凭证");
message.put("data", "http://127.0.0.1:8888/oauth2/authorization/gas");
// 转换响应消息内容对象为字节
byte[] bits = message.toJSONString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
// 设置响应对象状态码 401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 设置响应对象内容并且指定编码,否则在浏览器中会中文乱码
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json;charset=UTF-8");
// 返回响应对象
return response.writeWith( Mono.just(buffer) );
}
return chain.filter(exchange);
}).then(Mono.fromRunnable(() -> {
log.info("this is a post filter");
}));
}
}
上述代码的主要功能为拦截进入网关的每一个请求,若没有身份凭证(令牌)则返回/oauth2/authorization/{clientRegId}。
4.1.4. 重写DefaultServerOAuth2AuthorizationRequestResolver
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
//即后续认证成功后可重定向回前端指定页面
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));
});
});
});
}
}
4.1.5. 编写OAuth2User实现类
public class CustomUser implements OAuth2User, Serializable {
private Map<String, Object> attributes;
private Collection<? extends GrantedAuthority> authorities;
private String name;
public CustomUser(Map<String, Object> attributes, Collection<? extends GrantedAuthority> authorities, String name) {
this.attributes = attributes;
this.authorities = authorities;
this.name = name;
}
public CustomUser() {
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return name;
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
public void setName(String name) {
this.name = name;
}
}
4.1.6. 编写url白名单配置类
@Configuration
@ConfigurationProperties(prefix = "system")
public class UrlConfig {
// 配置文件使用list接收
private List<String> whiteList;
public List<String> getWhiteList() {
return whiteList;
}
public void setWhiteList(List<String> whiteList) {
this.whiteList = whiteList;
}
}
4.1.7. 编写userInfo过滤器
public class UserInfoRelayGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
private final static String USER_INFO_HEADER = "userInfo";
public UserInfoRelayGatewayFilterFactory() {
super(Object.class);
}
public GatewayFilter apply() {
return apply((Object) null);
}
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> exchange.getPrincipal()
// .log("token-relay-filter")
.filter(principal -> principal instanceof OAuth2AuthenticationToken)
.cast(OAuth2AuthenticationToken.class)
//.flatMap(authentication -> authorizedClient(exchange, authentication))
.map(OAuth2AuthenticationToken::getPrincipal)
.map(oAuth2User -> withUserInfoHeader(exchange, oAuth2User))
.defaultIfEmpty(exchange)
.flatMap(chain::filter);
}
private ServerWebExchange withUserInfoHeader(ServerWebExchange exchange, OAuth2User oAuth2User) {
//String userName = oAuth2User.getName();
Map<String, Object> userAttrs = oAuth2User.getAttributes();
if (oAuth2User instanceof OidcUser) {
userAttrs = ((OidcUser) oAuth2User).getUserInfo().getClaims();
}
String userAttrsJson = JsonUtils.toJson(userAttrs);
return exchange.mutate()
.request(r -> r.headers(headers -> headers.add(USER_INFO_HEADER, userAttrsJson)))
.build();
}
}
4.1.8. 编写ReactiveOAuth2UserService实现类
@Component
public class CustomOAuth2UserService implements ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> {
private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";
private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";
private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
};
private static final ParameterizedTypeReference<Map<String, String>> STRING_STRING_MAP = new ParameterizedTypeReference<Map<String, String>>() {
};
private WebClient webClient = WebClient.create();
@Override
public Mono<OAuth2User> loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
return Mono.fromCallable(() -> {
String tokenStr = userRequest.getAccessToken().getTokenValue();
try {
SignedJWT sjwt = SignedJWT.parse(tokenStr);
JWTClaimsSet claims = sjwt.getJWTClaimsSet();
claims.getSubject();
Collection<? extends GrantedAuthority> res = new ArrayList<>();
CustomUser customUser=new CustomUser( claims.getClaims(),res,claims.getSubject());
return customUser;
} catch (ParseException e) {
e.printStackTrace();
throw new OAuth2AuthenticationException(new OAuth2Error("500"),"服务器返回错误的jwt");
}
});
}
}
4.2. 资源服务器核心代码
4.2.1. 编写资源服务器yml
server:
port: 8090
servlet:
context-path: /resource
4.2.2. 编写资源服务器测试controller
@RestController
public class ArticleController {
@GetMapping("/user-info")
public String getUserName( @RequestHeader String userInfo){
return userInfo;
}
}
5. 登录测试
1. 直接访问资源服务器接口
由上图可看出无法直接访问资源服务器接口,前端接收到此返回信息后根据data中返回的路径加上redirect_uri(http://127.0.0.1:8888/oauth2/authorization/gas?redirect_uri=http://www.baidu.com),发送页面请求后可跳转至登录中心,认证成功后界面会重定向至redirect_uri所指定的界面(我这里写的百度)。
跳转至登录界面进行认证。
认证成功后重定向至redirect_uri所指定的界面(百度)。
2. 再次访问资源服务器接口
访问接口成功。
6. 参考链接
Spring Cloud Gateway作为OAuth2 Client_oauth2客户端接口为什么跳转到login_罗小爬EX的博客-CSDN博客
将Spring Cloud Gateway 与OAuth2模式一起使用_jwk-set-uri_ReLive27的博客-CSDN博客