SpringBoot-OAuth2-JWT-WebFlux-ClientServer

摘要

依赖

  • springboot官方提供了支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//webflux
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'


//本项目使用了自定义授权页面,所以引入视图模板依赖和基于webjar的bootstrap,jquery
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
//webjars https://www.webjars.org
implementation 'org.webjars:bootstrap:4.5.3'
implementation 'org.webjars.bower:jquery:3.5.1'
// 可以在html中去掉webjars的版本号,这样升级的时候直接修改上面引入的webjars中的版本号即可,页面中不需要修改
implementation 'org.webjars:webjars-locator:0.40'


//r2dbc mysql 库
implementation 'dev.miku:r2dbc-mysql'
//Spring r2dbc 抽象层
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'

//本项目中使用了lombok减少代码量,非必须依赖
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

ReactiveSecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.example.oauth2clientwebfluxdemo.config;


import com.example.oauth2clientwebfluxdemo.security.CustomReactiveAuthorizationManager;
import com.example.oauth2clientwebfluxdemo.security.CustomServerAccessDeniedHandler;
import com.example.oauth2clientwebfluxdemo.security.CustomServerAuthenticationEntryPoint;
import com.example.oauth2clientwebfluxdemo.security.CustomServerOAuth2AuthorizedClientRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;

import java.net.URI;

/**
* <h1>安全认证配置</h1>
* Created by hanqf on 2020/11/19 10:26.
*/


@Configuration
@EnableWebFluxSecurity //必要
@EnableReactiveMethodSecurity //启用@PreAuthorize注解配置
public class ReactiveSecurityConfig {

@Value("${oauth2.server.logout}")
private String oauth2_server_logout;

@Autowired
private CustomServerAccessDeniedHandler customServerAccessDeniedHandler;

@Autowired
private CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint;

@Autowired
private CustomReactiveAuthorizationManager customReactiveAuthorizationManager;

@Autowired
private CustomServerOAuth2AuthorizedClientRepository customServerOAuth2AuthorizedClientRepository;

/**
* 注册安全验证规则
* 配置方式与HttpSecurity基本一致
*/
@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { //定义SecurityWebFilterChain对安全进行控制,使用ServerHttpSecurity构造过滤器链;
return http.authorizeExchange()
//.anyExchange().authenticated() //所有请求都需要通过认证
.pathMatchers("/").authenticated()
//需要具备相应的角色才能访问
.pathMatchers("/user/**", "/user2/**").hasAuthority("SCOPE_any")
//不需要登录就可以访问
.pathMatchers("/login","/webjars/**").permitAll()

//其它路径需要根据指定的方法判断是否有权限访问,基于权限管理模型认证
.anyExchange().access(customReactiveAuthorizationManager)
//.anyExchange().permitAll()
.and()
.csrf().disable() //关闭CSRF(Cross-site request forgery)跨站请求伪造
//必须post访问
.logout().logoutUrl("/logout").logoutSuccessHandler(serverLogoutSuccessHandler())
.and()

//开启oauth2登录认证
.oauth2Login()
.and()
//开启基于oauth2的客户端信息
.oauth2Client()
//客户端信息基于数据库,基于内存去掉下面配置即可
.authorizedClientRepository(customServerOAuth2AuthorizedClientRepository)
.and().exceptionHandling()
.accessDeniedHandler(customServerAccessDeniedHandler)
.authenticationEntryPoint(customServerAuthenticationEntryPoint)
.and()
.build();
}


/**
* 退出重定向到认证登录页面,默认"/login?logout"
*/
public ServerLogoutSuccessHandler serverLogoutSuccessHandler(){
RedirectServerLogoutSuccessHandler redirectServerLogoutSuccessHandler = new RedirectServerLogoutSuccessHandler();
redirectServerLogoutSuccessHandler.setLogoutSuccessUrl(URI.create(oauth2_server_logout));
return redirectServerLogoutSuccessHandler;
}
}

CustomReactiveAuthorizationManager

基于RBAC权限认证管理模型的认证方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.example.oauth2resourceserverwebfluxdemo.security;

import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;

import java.util.HashSet;
import java.util.Set;

/**
* <h1>ReactiveAuthorizationManager</h1>
* Created by hanqf on 2020/11/30 12:05.
*/

@Component
public class CustomReactiveAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext object) {
return authentication.map(auth -> {
ServerHttpRequest request = object.getExchange().getRequest();
Object principal = auth.getPrincipal();
String username;
if (principal instanceof Jwt) {
username = ((Jwt) principal).getClaimAsString("user_name");
} else {
username = principal.toString();
}
boolean hasPerssion = false;
if (StringUtils.hasText(username) && !"anonymousUser".equals(username)) {
//根据用户名查询用户资源权限,这里应该访问数据库查询
Set<String> uris = new HashSet<>();
for (String uri : uris) {
//验证用户拥有的资源权限是否与请求的资源相匹配
if (new AntPathMatcher().match(uri, request.getURI().toString())) {
hasPerssion = true;
break;
}
}
}

//暂时全部返回true
hasPerssion = true;
return new AuthorizationDecision(hasPerssion);
});
}
}

CustomServerAccessDeniedHandler

没有权限时的处理方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.example.oauth2resourceserverwebfluxdemo.security;


import com.example.oauth2resourceserverwebfluxdemo.exception.AjaxResponse;
import com.example.oauth2resourceserverwebfluxdemo.exception.CustomException;
import com.example.oauth2resourceserverwebfluxdemo.exception.CustomExceptionType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
* <h1>没有权限时的处理方式</h1>
* Created by hanqf on 2020/11/20 11:56.
*/

@Component
public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler {
@SneakyThrows
@Override
public Mono<Void> handle(ServerWebExchange serverWebExchange, AccessDeniedException e) {
return setErrorResponse(serverWebExchange.getResponse(),e.getMessage());
}

protected Mono<Void> setErrorResponse(ServerHttpResponse response, String message) throws JsonProcessingException {
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
ObjectMapper objectMapper = new ObjectMapper();
AjaxResponse ajaxResponse = AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, message));
return response.writeWith(Mono.just(response.bufferFactory().wrap(objectMapper.writeValueAsBytes(ajaxResponse))));

}
}

CustomServerAuthenticationEntryPoint

token格式错误或过期时的处理方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.oauth2resourceserverwebfluxdemo.security;


import com.example.oauth2resourceserverwebfluxdemo.exception.AjaxResponse;
import com.example.oauth2resourceserverwebfluxdemo.exception.CustomException;
import com.example.oauth2resourceserverwebfluxdemo.exception.CustomExceptionType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
* <h1>ServerAuthenticationEntryPoint</h1>
* Created by hanqf on 2020/11/20 12:01.
* <p>
* token格式错误或过期时的处理方式
*/
@Component
public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
@SneakyThrows
@Override
public Mono<Void> commence(ServerWebExchange serverWebExchange, AuthenticationException e) {
return setErrorResponse(serverWebExchange.getResponse(), e.getMessage());
}

protected Mono<Void> setErrorResponse(ServerHttpResponse response, String message) throws JsonProcessingException {
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
ObjectMapper objectMapper = new ObjectMapper();
AjaxResponse ajaxResponse = AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR, message));
return response.writeWith(Mono.just(response.bufferFactory().wrap(objectMapper.writeValueAsBytes(ajaxResponse))));

}
}

WebFluxConfig

配置跨域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.oauth2resourceserverwebfluxdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.config.CorsRegistry;
import org.springframework.web.reactive.config.WebFluxConfigurer;

/**
* <h1>WebFlux配置类</h1>
* Created by hanqf on 2020/11/18 17:43.
*
* 我们若需要配置Spring WebFlux只需让配置配实现接口WebFluxConfigurer,
* 这样我们既能保留Spring Boot给WebFlux配置又能添加我们的定制配置。
* 若我们向完全控制WebFlux,则在配置类添加注解@EnableWebFlux
* 配置方式和Spring MVC类似
*/

@Configuration
public class WebFluxConfig implements WebFluxConfigurer {

//跨域设置
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") //添加映射路径,“/**”表示对所有的路径实行全局跨域访问权限的设置
.allowedMethods("GET","POST", "PUT", "DELETE") //开放哪些Http方法,允许跨域访问
.allowedHeaders("*") //允许HTTP请求中的携带哪些Header信息
//When allowCredentials is true, allowedOrigins cannot contain the special value "*"since that cannot be set on the "Access-Control-Allow-Origin" response header.
// To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
//.allowedOrigins("*") //开放哪些ip、端口、域名的访问权限
.allowedOriginPatterns("*")
.allowCredentials(true); //是否允许发送Cookie信息

}
}

CustomServerOAuth2AuthorizedClientRepository

基于jdbc存储token信。

这里有一个注意事项:查询时不要使用.fetch()方法,其会按照字段名称的字母排序进行赋值,导致结果中key和value匹配混乱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
package com.example.oauth2clientwebfluxdemo.security;


import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;

/**
* <h1>ServerOAuth2AuthorizedClientRepository</h1>
* Created by hanqf on 2020/12/1 09:58.
*/

@Component
@Slf4j
public class CustomServerOAuth2AuthorizedClientRepository implements ServerOAuth2AuthorizedClientRepository {
// @formatter:off
private static final String COLUMN_NAMES = "client_registration_id, "
+ "principal_name, "
+ "access_token_type, "
+ "access_token_value, "
+ "access_token_issued_at, "
+ "access_token_expires_at, "
+ "access_token_scopes, "
+ "refresh_token_value, "
+ "refresh_token_issued_at";


private static final String TABLE_NAME = "oauth2_authorized_client";
private static final String PK_FILTER = "client_registration_id = ? AND principal_name = ?";
// @formatter:on
// @formatter:off
private static final String LOAD_AUTHORIZED_CLIENT_SQL = "SELECT " + COLUMN_NAMES
+ " FROM " + TABLE_NAME
+ " WHERE " + PK_FILTER;

// @formatter:off
private static final String SAVE_AUTHORIZED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME
+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
private static final String REMOVE_AUTHORIZED_CLIENT_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + PK_FILTER;
// @formatter:on
// @formatter:off
private static final String UPDATE_AUTHORIZED_CLIENT_SQL = "UPDATE " + TABLE_NAME
+ " SET access_token_type = ?, access_token_value = ?, access_token_issued_at = ?,"
+ " access_token_expires_at = ?, access_token_scopes = ?,"
+ " refresh_token_value = ?, refresh_token_issued_at = ?"
+ " WHERE " + PK_FILTER;
// @formatter:on
@Resource
private DatabaseClient databaseClient;
@Autowired
private ReactiveClientRegistrationRepository reactiveClientRegistrationRepository;
// @formatter:on

/**
* 这里有一个注意事项:查询时不要使用.fetch()方法,其会按照字段名称的字母排序进行赋值,导致结果中key和value匹配混乱
*/
@Override
public <T extends OAuth2AuthorizedClient> Mono<T> loadAuthorizedClient(String clientRegistrationId, Authentication principal, ServerWebExchange exchange) {
Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
Assert.hasText(principal.getName(), "principalName cannot be empty");

Mono<OAuth2AuthorizedClient> oAuth2AuthorizedClientMono = databaseClient.sql(LOAD_AUTHORIZED_CLIENT_SQL)
.bind(0, clientRegistrationId)
.bind(1, principal.getName())
.map(row -> {
String clientRegistrationId1 = row.get("client_registration_id", String.class);
String access_token_type = row.get("access_token_type", String.class);


OAuth2AccessToken.TokenType tokenType = null;
if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase(access_token_type)) {
tokenType = OAuth2AccessToken.TokenType.BEARER;
}

String tokenValue = new String(row.get("access_token_value", byte[].class), StandardCharsets.UTF_8);
Instant issuedAt = row.get("access_token_issued_at", LocalDateTime.class).atZone(ZoneId.systemDefault()).toInstant();
Instant expiresAt = row.get("access_token_expires_at", LocalDateTime.class).atZone(ZoneId.systemDefault()).toInstant();
Set<String> scopes = Collections.emptySet();
String accessTokenScopes = row.get("access_token_scopes", String.class);
if (accessTokenScopes != null) {
scopes = StringUtils.commaDelimitedListToSet(accessTokenScopes);
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, tokenValue, issuedAt, expiresAt, scopes);
OAuth2RefreshToken refreshToken = null;
byte[] refreshTokenValue = row.get("refresh_token_value", byte[].class);
if (refreshTokenValue != null) {
tokenValue = new String(refreshTokenValue, StandardCharsets.UTF_8);
issuedAt = null;
LocalDateTime refreshTokenIssuedAt = row.get("refresh_token_issued_at", LocalDateTime.class);
if (refreshTokenIssuedAt != null) {
issuedAt = refreshTokenIssuedAt.atZone(ZoneId.systemDefault()).toInstant();
}
refreshToken = new OAuth2RefreshToken(tokenValue, issuedAt);
}
String principalName = row.get("principal_name", String.class);

final OAuth2RefreshToken refreshToken1 = refreshToken;

Mono<ClientRegistration> clientRegistrationMono = reactiveClientRegistrationRepository
.findByRegistrationId(clientRegistrationId1);
return clientRegistrationMono
.switchIfEmpty(Mono.error(new DataRetrievalFailureException(
"The ClientRegistration with id '" + clientRegistrationId1 + "' exists in the data source, "
+ "however, it was not found in the ClientRegistrationRepository.")))
.map(clientRegistration -> new OAuth2AuthorizedClient(clientRegistration, principalName, accessToken, refreshToken1));
}).first().flatMap(oAuth2AuthorizedClientMono1 -> oAuth2AuthorizedClientMono1);

return (Mono<T>) oAuth2AuthorizedClientMono.doOnNext(unused -> log.info("select client token info success!"));
}

@Override
public Mono<Void> saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, ServerWebExchange exchange) {

Assert.notNull(authorizedClient, "authorizedClient cannot be null");
Assert.notNull(principal, "principal cannot be null");

return this.loadAuthorizedClient(authorizedClient.getClientRegistration().getRegistrationId(), principal, exchange)
.flatMap((Function<OAuth2AuthorizedClient, Mono<Optional<OAuth2AuthorizedClient>>>) oAuth2AuthorizedClient -> Mono.just(Optional.of(oAuth2AuthorizedClient)))
.defaultIfEmpty(Optional.empty())
.flatMap((Function<Optional<OAuth2AuthorizedClient>, Mono<Void>>) oAuth2AuthorizedClient -> {
if(!oAuth2AuthorizedClient.isPresent()){
return insertAuthorizedClient(authorizedClient,principal);
}else {
return updateAuthorizedClient(authorizedClient, principal);
}
});
}

private Mono<Void> updateAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
return databaseClient.sql(UPDATE_AUTHORIZED_CLIENT_SQL)
.bind(0, authorizedClient.getAccessToken().getTokenType().getValue())
.bind(1, authorizedClient.getAccessToken().getTokenValue().getBytes(StandardCharsets.UTF_8))
.bind(2, timeFromInstant(authorizedClient.getAccessToken().getIssuedAt()))
.bind(3, timeFromInstant(authorizedClient.getAccessToken().getExpiresAt()))
.bind(4, StringUtils.collectionToCommaDelimitedString(authorizedClient.getAccessToken().getScopes()))
.bind(5, authorizedClient.getRefreshToken().getTokenValue().getBytes(StandardCharsets.UTF_8))
.bind(6, timeFromInstant(authorizedClient.getRefreshToken().getIssuedAt()))
.bind(7, authorizedClient.getClientRegistration().getRegistrationId())
.bind(8, principal.getName())
.then()
.doOnNext(unused -> log.info("update client token info success!"));
}

private Mono<Void> insertAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
return databaseClient.sql(SAVE_AUTHORIZED_CLIENT_SQL)
.bind(0, authorizedClient.getClientRegistration().getRegistrationId())
.bind(1, principal.getName())
.bind(2, authorizedClient.getAccessToken().getTokenType().getValue())
.bind(3, authorizedClient.getAccessToken().getTokenValue().getBytes(StandardCharsets.UTF_8))
.bind(4, timeFromInstant(authorizedClient.getAccessToken().getIssuedAt()))
.bind(5, timeFromInstant(authorizedClient.getAccessToken().getExpiresAt()))
.bind(6, StringUtils.collectionToCommaDelimitedString(authorizedClient.getAccessToken().getScopes()))
.bind(7, authorizedClient.getRefreshToken().getTokenValue().getBytes(StandardCharsets.UTF_8))
.bind(8, timeFromInstant(authorizedClient.getRefreshToken().getIssuedAt()))
.then()
.doOnNext(unused -> log.info("insert client token info success!"));
}

@Override
public Mono<Void> removeAuthorizedClient(String clientRegistrationId, Authentication principal, ServerWebExchange exchange) {

Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
Assert.hasText(principal.getName(), "principalName cannot be empty");

return databaseClient.sql(REMOVE_AUTHORIZED_CLIENT_SQL)
.bind(0, clientRegistrationId)
.bind(1, principal.getName())
.then()
.doOnNext(unused -> log.info("remove client token info success!"));
}

private String timeFromInstant(Instant instant) {
return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.ofInstant(instant, ZoneId.systemDefault()));
}

}

AuthController

自定义登录跳转页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.oauth2clientwebfluxdemo.controller;

import com.example.oauth2clientwebfluxdemo.security.CustomServerOAuth2AuthorizedClientRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.HashMap;
import java.util.Map;

/**
* <h1>login</h1>
* Created by hanqf on 2020/11/30 18:02.
*/

@Controller
public class AuthController {

@Autowired
ReactiveClientRegistrationRepository reactiveClientRegistrationRepository;

@GetMapping("/login")
public String login(Model model) {
Map<String, String> map = new HashMap<>();
if (reactiveClientRegistrationRepository instanceof InMemoryReactiveClientRegistrationRepository) {
((InMemoryReactiveClientRegistrationRepository) reactiveClientRegistrationRepository).forEach(registrations -> {
String registrationId = registrations.getRegistrationId();
String clientName = registrations.getClientName();
System.out.println(registrationId + "---" + clientName);
map.put(registrationId, clientName);
});
}
model.addAttribute("registrations", map);

return "oauth2/login";
}

}

login.html

自定义登录页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>登录</title>

<link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.min.css}"/>

<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/bootstrap/js/bootstrap.min.js}"></script>

</head>
<body>

<div class="container">
<h2 class="form-signin-heading">Login with OAuth 2.0</h2>
<table class="table table-striped">

<tr th:each="registration: ${registrations}">
<td><a th:href="@{'/oauth2/authorization/'+${registration.key}}" th:text="${registration.value}"></a></td>
</tr>

</table>
</div>

<form th:action="@{/logout}" class="container" method="post">
<button type="submit">Logout</button>
</form>
</body>
</html>

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102

server:
port: 8099

oauth2:
server:
url: http://localhost:8080
logout: ${oauth2.server.url}/logout #认证服务器logout地址


spring:
application:
name: oauth2-client-webflux
#资源国际化
messages:
basename: static/i18n/messages
encoding: utf-8

#thymeleaf
thymeleaf:
cache: false
enabled: true
encoding: UTF-8
mode: HTML
prefix: classpath:/templates/
servlet:
content-type: text/html
suffix: .html


#r2dbc mysql
r2dbc:
url: r2dbc:mysql://localhost:3306/springboot?useUnicode=true&characterEncoding=utf-8&useTimezone=true&serverTimezone=GMT%2B8
username: root
password: newpwd
pool:
enabled: true
initial-size: 5
max-size: 20
max-idle-time: 30m



#oauth2
# 支持多客户端认证方式
security:
oauth2:
client:
registration:
# /oauth2/authorization/flux-client # 认证地址
flux-client: # 注册名称
client-id: postman # 客户端登录用户名称
client-secret: postman # 客户端登录密码
authorization-grant-type: authorization_code #认证方式为code
#回调地址,需要配置到认证服务器中
redirect-uri: http://localhost:8099/login/oauth2/code/flux-client
scope: any #授权范围
client-name: 客户端1 #显示名称
# # /oauth2/authorization/flux-client2
flux-client2:
client-id: demo-client
client-secret: demo-client
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8099/login/oauth2/code/flux-client2
scope: any
client-name: 客户端2


google: # google
client-id: xxxxxxxxx
client-secret: xxxxxxx
client-name: Google认证


facebook: # facebook
client-id: xxxxxxxxx
client-secret: xxxxxxx
client-name: Facebook认证


github: # github
client-id: xxxxxxxxx
client-secret: xxxxxxx
client-name: Github认证

provider:
flux-client: # 注册客户端的认证信息
authorization-uri: ${oauth2.server.url}/oauth/authorize # 认证服务器授权地址
token-uri: ${oauth2.server.url}/oauth/token # 认证服务器token地址
user-info-uri: http://localhost:8080/userInfo # 认证服务器用户信息地址
userNameAttribute: username # 指定user-info-uri返回map中的属性名称用于表示用户名
flux-client2:
authorization-uri: ${oauth2.server.url}/oauth/authorize
token-uri: ${oauth2.server.url}/oauth/token
user-info-uri: http://localhost:8080/userInfo
userNameAttribute: username
debug: true

# 可以打印sql
logging:
level:
org.springframework.data.r2dbc: DEBUG

ResController

请求资源服务示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package com.example.oauth2clientwebfluxdemo.controller;

import com.example.oauth2clientwebfluxdemo.exception.AjaxResponse;
import com.example.oauth2clientwebfluxdemo.exception.CustomException;
import com.example.oauth2clientwebfluxdemo.exception.CustomExceptionType;
import com.example.oauth2clientwebfluxdemo.security.CustomServerOAuth2AuthorizedClientRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.HashMap;
import java.util.Map;

/**
* <h1>获取资源服务器数据</h1>
* Created by hanqf on 2020/11/7 22:47.
*/

@RestController
@RequestMapping("/res")
public class ResController {

private static final WebClient CLIENT = WebClient.create("http://localhost:8083");


@Autowired
private CustomServerOAuth2AuthorizedClientRepository customServerOAuth2AuthorizedClientRepository;

/**
* 获取资源服务器的数据
*/
@RequestMapping("/res1")
public Mono<AjaxResponse> getRes(Authentication principal, ServerWebExchange exchange) {
if (principal instanceof OAuth2AuthenticationToken) {
String authorizedClientRegistrationId = ((OAuth2AuthenticationToken) principal).getAuthorizedClientRegistrationId();
Mono<OAuth2AuthorizedClient> oAuth2AuthorizedClientMono = customServerOAuth2AuthorizedClientRepository.loadAuthorizedClient(authorizedClientRegistrationId, principal, exchange);

return oAuth2AuthorizedClientMono.flatMap(client -> {
String tokenValue = client.getAccessToken().getTokenValue();
String tokenType = client.getAccessToken().getTokenType().getValue();
return CLIENT.get()
.uri("/res/res1")
//增加了Bearer安全认证,所以这里需要传递header认证信息
.header(HttpHeaders.AUTHORIZATION,
tokenType + " " + tokenValue)
.retrieve()//异步接收服务端响应
.bodyToMono(AjaxResponse.class)
.retry(3)
.defaultIfEmpty(AjaxResponse.success("返回结果为Null"));
});
} else {
return Mono.just(AjaxResponse.error(new CustomException(CustomExceptionType.SYSTEM_ERROR,"Authentication类型转换异常")));
}
}

@RequestMapping("/user")
public Mono<AjaxResponse> getUser(Authentication principal, ServerWebExchange exchange) {
if (principal instanceof OAuth2AuthenticationToken) {
String authorizedClientRegistrationId = ((OAuth2AuthenticationToken) principal).getAuthorizedClientRegistrationId();
Mono<OAuth2AuthorizedClient> oAuth2AuthorizedClientMono = customServerOAuth2AuthorizedClientRepository.loadAuthorizedClient(authorizedClientRegistrationId, principal, exchange);

return oAuth2AuthorizedClientMono.flatMap(client -> {
String tokenValue = client.getAccessToken().getTokenValue();
String tokenType = client.getAccessToken().getTokenType().getValue();
return CLIENT.post()
.uri("/user")
//增加了Bearer安全认证,所以这里需要传递header认证信息
.header(HttpHeaders.AUTHORIZATION,
tokenType + " " + tokenValue)
.retrieve()//异步接收服务端响应
.bodyToMono(AjaxResponse.class)
.retry(3)
.defaultIfEmpty(AjaxResponse.success("返回结果为Null"));
});
} else {
return Mono.just(AjaxResponse.error(new CustomException(CustomExceptionType.SYSTEM_ERROR,"Authentication类型转换异常")));
}
}

@RequestMapping("/rbac")
public Mono<AjaxResponse> getRbac(Authentication principal, ServerWebExchange exchange) {
if (principal instanceof OAuth2AuthenticationToken) {
String authorizedClientRegistrationId = ((OAuth2AuthenticationToken) principal).getAuthorizedClientRegistrationId();
Mono<OAuth2AuthorizedClient> oAuth2AuthorizedClientMono = customServerOAuth2AuthorizedClientRepository.loadAuthorizedClient(authorizedClientRegistrationId, principal, exchange);

return oAuth2AuthorizedClientMono.flatMap(client -> {
String tokenValue = client.getAccessToken().getTokenValue();
String tokenType = client.getAccessToken().getTokenType().getValue();
return CLIENT.get()
.uri("/rbac")
//增加了Bearer安全认证,所以这里需要传递header认证信息
.header(HttpHeaders.AUTHORIZATION,
tokenType + " " + tokenValue)
.retrieve()//异步接收服务端响应
.bodyToMono(AjaxResponse.class)
.retry(3)
.defaultIfEmpty(AjaxResponse.success("返回结果为Null"));
});
} else {
return Mono.just(AjaxResponse.error(new CustomException(CustomExceptionType.SYSTEM_ERROR,"Authentication类型转换异常")));
}
}

@RequestMapping("/userInfo")
public Mono<Map> getuserInfo(Authentication principal, ServerWebExchange exchange) {
if (principal instanceof OAuth2AuthenticationToken) {
String authorizedClientRegistrationId = ((OAuth2AuthenticationToken) principal).getAuthorizedClientRegistrationId();
Mono<OAuth2AuthorizedClient> oAuth2AuthorizedClientMono = customServerOAuth2AuthorizedClientRepository.loadAuthorizedClient(authorizedClientRegistrationId, principal, exchange);

return oAuth2AuthorizedClientMono.flatMap(client -> {
String tokenValue = client.getAccessToken().getTokenValue();
String tokenType = client.getAccessToken().getTokenType().getValue();
return CLIENT.get()
.uri("/userInfo")
//增加了Bearer安全认证,所以这里需要传递header认证信息
.header(HttpHeaders.AUTHORIZATION,
tokenType + " " + tokenValue)
.retrieve()//异步接收服务端响应
.bodyToMono(Map.class)
.retry(3)
.defaultIfEmpty(new HashMap());
});
} else {
return Mono.error(new CustomException(CustomExceptionType.SYSTEM_ERROR,"Authentication类型转换异常"));
}
}

}