SpringBoot-OAuth2-JWT-ClientServer

摘要

依赖

  • springboot官方提供了支持

1
2
3
4
5
6
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

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

WebSecurityConfig

  • springboot没有为client-server设置独立的注解和配置类,而是把这部分功能整合到了spring-security中

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
package com.example.oauth2clientdemo2.config;

import com.example.oauth2clientdemo2.exception.CustomException;
import com.example.oauth2clientdemo2.security.CustomAccessDeniedHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.List;

/**
* <h1></h1>
* Created by hanqf on 2020/11/9 18:11.
*/
@EnableWebSecurity
@Configuration
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

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

@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;


/**
* 指定不拦截的路径规则
*/
@Override
public void configure(WebSecurity web) throws Exception {
//设置不需要拦截的路径,也就是不需要认证的路径
web.ignoring().antMatchers("/webjars/**");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
//logout跳转到认证服务器的logout
http.logout().logoutSuccessUrl(oauth2_server_logout);

//配置认证规则
http.authorizeRequests()
.mvcMatchers("/oauth/login").permitAll()
////所有路径都需要登录
.antMatchers("/").authenticated()
////需要具备相应的角色才能访问,这里返回的权限是scope,所以还是使用rbac验证吧
.antMatchers("/user/**", "/user2/**").hasAuthority("SCOPE_any")
.anyRequest().access("@rbacService.hasPerssion(request,authentication)");

//开启oauth2登录认证
http.oauth2Login();

//开启基于oauth2的客户端信息
http.oauth2Client();

http.csrf().disable();

http.exceptionHandling().accessDeniedHandler(customAccessDeniedHandler);
}

}

WebSecurityConfig说明

与oauth2-client相关的代码就如下两行

1
2
3
4
5
 //开启oauth2登录认证
http.oauth2Login();

//开启基于oauth2的客户端信息
http.oauth2Client();

配置文件

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
oauth2:
server:
url: http://localhost:8080
logout: ${oauth2.server.url}/logout #可以实现单点登录,不能实现单点登出

spring:
#oauth2客户端配置,默认基于内存,如果基于数据库,需要在config配置类进行相应的配置
security:
oauth2:
client:
registration: #支持多租户
# /postman/oauth2/authorization/my-client
my-client: # 1 注册客户端名称,随意指定,但是要与provider的配置相一致
client-id: postman # 2 客户端ID
client-secret: postman # 3 客户端密码
authorization-grant-type: authorization_code # 4 认证类型
#默认重定向URI模板是{baseUrl}/login/oauth2/code/{registrationId}。
#registrationId是ClientRegistration的唯一标识符。
redirect-uri: http://localhost:8088/postman/login/oauth2/code/my-client # 5 回调地址,需要配置到数据表中,默认写法,注意最后的路径是注册客户端名称
scope: any #请求范围
client-name: 客户端1
# /postman/oauth2/authorization/my-client2
my-client2: # 1 注册客户端名称,随意指定,但是要与provider的配置相一致
client-id: postman # 2 客户端ID
client-secret: postman # 3 客户端密码
authorization-grant-type: authorization_code # 4 认证类型
redirect-uri: http://localhost:8088/postman/login/oauth2/code/my-client2 # 5 回调地址,需要配置到数据表中,默认写法,注意最后的路径是注册客户端名称
scope: any #请求范围
client-name: 客户端2

# CommonOAuth2Provider 中定义了google,facebook,github,okta的默认provider信息,所以这里不需要为其配置provider信息
# /postman/oauth2/authorization/google
# 重定向后:https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=xxxxxxxxx&scope=openid%20profile%20email&state=jO3PpuX2IzwOFemdd7FHBIHeeSPcxDwiP-mqDBTjdfw%3D&redirect_uri=http://localhost:8088/postman/login/oauth2/code/google&nonce=-sxhj2hjgrc7krSHSjL3qz_W90xPEX9nlP6SYzAfktY
google: # google帐号认证,跳转到google登录页面
client-id: xxxxxxxxx # 客户端ID
client-secret: xxxxxxx # 客户端密码
client-name: Google认证

# /postman/oauth2/authorization/facebook
# https://www.facebook.com/v2.8/dialog/oauth?response_type=code&client_id=xxxxxxxxx&scope=public_profile%20email&state=BVZM3OW2EUS82pbjJdpQfXvtXbFbMwaUe_lRMOt4BW4%3D&redirect_uri=http://localhost:8088/postman/login/oauth2/code/facebook
facebook: # facebook帐号登录,跳转到facebook登录页面
client-id: xxxxxxxxx # 客户端ID
client-secret: xxxxxxx # 客户端密码
client-name: Facebook认证

# /postman/oauth2/authorization/github
# 重定向后:https://github.com/login?client_id=xxxxxxxxx&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3Dxxxxxxxxx%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8088%252Fpostman%252Flogin%252Foauth2%252Fcode%252Fgithub%26response_type%3Dcode%26scope%3Dread%253Auser%26state%3DmXtRWMQy8NaqVsFiie-NyYuy2z8OErrdq_VsuejDOPE%253D
# api文档:https://docs.github.com/cn/free-pro-team@latest/rest
github: # github帐号登录,跳转到github登录页面
client-id: xxxxxxxxx # 客户端ID
client-secret: xxxxxxx # 客户端密码
client-name: Github认证

provider:
my-client: # 6 注册客户端名称
authorization-uri: ${oauth2.server.url}/oauth/authorize # 7 认证地址
token-uri: ${oauth2.server.url}/oauth/token # 8 获取token地址
user-info-uri: http://localhost:8080/userInfo # 9 获取用户信息地址,必须配置
userNameAttribute: username # 10 指定用户信息中哪个属性是用户名称
my-client2: # 6 注册客户端名称
authorization-uri: ${oauth2.server.url}/oauth/authorize # 7 认证地址
token-uri: ${oauth2.server.url}/oauth/token # 8 获取token地址
user-info-uri: http://localhost:8080/userInfo # 9 获取用户信息地址,必须配置
userNameAttribute: username # 10 指定用户信息中哪个属性是用户名称

配置文件说明

  • springboot默认实现了google,facebook,github,okta的provider,但是其它认证服务器就需要自己指定provider信息。

  • 这里需要为其指定user-info-uri,这个url提供认证用户的信息,需要认证服务端提供(也可以配置到受认证服务保护的资源服务中),所以我们需要对认证服务进行改造

  • userNameAttribute表示从user-info-uri接口返回的信息中哪个属性名称表示用户名称

  • 如果客户端信息只有一个,则访问当前客户端服务就会直接跳转到认证服务器的登录页面

  • 如果客户端配置了多个,则会先跳转到login页面,需要选择要访问的认证服务器,后文有介绍如何自定义login页面

AuthServer改造

UserController

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
package com.example.oauth2authserverdemo.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
public class UserController {

@RequestMapping("/userInfo")
public Map<String, Object> userInfo(Authentication authentication){
Map<String,Object> map = new HashMap<>();
Object principal = authentication.getPrincipal();
if(principal instanceof Jwt){
map.put("username", ((Jwt) principal).getClaim("user_name"));

//客户端获取用户信息时,将附加信息一块返回给客户端
map.putAll(((Jwt) principal).getClaims());
}

return map;
}
}

UserController说明

  • 这个接口就是普通的controller接口,其返回一个map信息,里面至少包含一个用户名称信息。

  • 因为client-server与auth-server之间是通过基于JWT的access-token进行访问的,所以这个接口实际上也是一个资源接口,需要为认证服务器配置资源服务。

SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//认证服务器同时作为资源服务器,其目的是可以对外提供访问的资源,比如"客户端认证获取用户信息"
//鉴权时只支持Bearer Token的形式,不支持url后加参数access_token
http.oauth2ResourceServer().jwt() //开启oauth2资源认证
//默认情况下,权限是scope,而我们希望使用的是用户的角色,所以这里需要通过转换器进行处理
.jwtAuthenticationConverter(jwt -> { //通过自定义Converter来指定权限,Converter是函数接口,当前上下问参数为JWT对象
Collection<SimpleGrantedAuthority> authorities =
((Collection<String>) jwt.getClaims()
.get("authorities")).stream() //获取JWT中的authorities
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());

//如果希望保留scope的权限,可以取出scope数据然后合并到一起,这样因为不是以ROLE_开头,所以需要使用hasAuthority('SCOPE_any')的形式
Collection<SimpleGrantedAuthority> scopes = ((Collection<String>) jwt.getClaims()
.get("scope")).stream().map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toSet());
//合并权限
authorities.addAll(scopes);
return new JwtAuthenticationToken(jwt, authorities);
});

SecurityConfig说明

  • 如果该接口是登录后就可以访问,只需要按如下配置即可:

1
http.oauth2ResourceServer().jwt();

配置文件

1
2
3
4
5
6
7
spring:
#oauth2 配置
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:oauth2_key.pub
  • 将公钥文件放到对应的路径下即可

自定义login页面

  • 当客户端配置信息中指定了多个client时,访问当前服务时会先跳转到login页面,需要选择要访问哪个认证服务器。

依赖

1
2
3
4
5
6
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'

LoginController

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
package com.example.oauth2clientdemo2.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

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

/**
* <h1>LoginController</h1>
* Created by hanqf on 2020/11/14 00:19.
*/


@Controller
@Log4j2
public class LoginController {

private final ClientRegistrationRepository clientRegistrationRepository;

public LoginController(ClientRegistrationRepository clientRegistrationRepository) {
this.clientRegistrationRepository = clientRegistrationRepository;
}

/**
* 自定义登录页面,多租户登录时先显示该页面,由用户选择要使用的认证服务
* @param model
* @return
*/
@RequestMapping("/oauth/login")
public String login(Model model){
Map<String,String> map = new HashMap<>();
if(clientRegistrationRepository instanceof InMemoryClientRegistrationRepository){
((InMemoryClientRegistrationRepository)clientRegistrationRepository).forEach(registrations->{
String registrationId = registrations.getRegistrationId();
String clientName = registrations.getClientName();
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
<!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>

</body>
</html>

WebSecurityConfig

1
2
3
4
//开启oauth2登录认证
http.oauth2Login()
//自定义登录页面
.loginPage("/oauth/login");

OAuth2AuthorizedClientService

  • 默认每次客户端获取到的token信息都是存储在内存中的,如果服务器重启则token就会失效,可以将token保存到数据库中

创建数据表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 客户端认证信息表,只需建表,数据会自动创建
CREATE TABLE `oauth2_authorized_client` (
`client_registration_id` varchar(100) COLLATE utf8mb4_bin NOT NULL COMMENT '客户端注册Id',
`principal_name` varchar(200) COLLATE utf8mb4_bin NOT NULL COMMENT '登录用户名称',
`access_token_type` varchar(100) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '验证类型,这里是bear',
`access_token_value` blob DEFAULT NULL COMMENT 'access_token的值',
`access_token_issued_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT 'access_token的创建时间',
`access_token_expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT 'access_token的过期时间',
`access_token_scopes` varchar(1000) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '授权范围',
`refresh_token_value` blob DEFAULT NULL COMMENT 'refresh_token的值',
`refresh_token_issued_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT 'refresh_token创建时间',
`created_at` timestamp NOT NULL DEFAULT current_timestamp() COMMENT '数据创建时间',
`update_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT '数据最后更新时间',
PRIMARY KEY (`client_registration_id`,`principal_name`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

添加数据库依赖

1
2
3
4
//mysql
runtimeOnly 'mysql:mysql-connector-java'
//为了使用datasource
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

配置文件添加数据库的配置

1
2
3
4
5
6
7
8
9
10
11
12
spring:
#数据源配置
datasource:
url: jdbc:mysql://localhost:3306/springboot?useUnicode=true&characterEncoding=utf-8&useTimezone=true&serverTimezone=GMT%2B8
username: root
password: newpwd
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
connection-timeout: 30000 #毫秒,默认30秒
idle-timeout: 600000 #毫秒,默认10分钟
max-lifetime: 1800000 #毫秒,默认30分钟
maximum-pool-size: 10 #默认10

WebSecurityConfig增加JdbcOAuth2AuthorizedClientService的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 基于数据库的客户端信息配置,认证数据会自动创建到数据表中
*/
@Bean
public OAuth2AuthorizedClientService authorizedClientService(JdbcOperations jdbcOperations, ClientRegistrationRepository clientRegistrationRepository) {
JdbcOAuth2AuthorizedClientService authorizedClientService =
new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
return authorizedClientService;
}

@Autowired
private OAuth2AuthorizedClientService oAuth2AuthorizedClientService;


//开启基于oauth2的客户端信息
http.oauth2Client()
//客户端信息基于数据库,基于内存去掉下面配置即可
.authorizedClientService(oAuth2AuthorizedClientService);

访问资源服务

  • WebSecurityConfig配置类增加如下配置

  • 其目的就是在使用RestTemplate访问资源服务时,在请求头中增加Bearer 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
/**
* 调用Resource Server服务使用RestTemplate,当调用的Resource Server时候我们时需要使用Bearer Token在头部传递Access Token;
* RestTemplateAutoConfiguration已经给我们自动配置了RestTemplateBuilder来配置RestTemplate,我们需要通过RestTemplateCustomizer来对RestTemplate来定制
*/
@Bean
RestTemplateCustomizer restTemplateCustomizer(OAuth2AuthorizedClientService clientService) {
return restTemplate -> { //1 RestTemplateCustomizer时函数接口,入参是RestTemplate
List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
if (CollectionUtils.isEmpty(interceptors)) {
interceptors = new ArrayList<>();
}
interceptors.add((request, body, execution) -> { //2 通过增加RestTemplate拦截器,让每次请求添加Bearer Token(Access Token);ClientHttpRequestInterceptor是函数接口,可用Lambda表达式来实现
OAuth2AuthenticationToken auth = (OAuth2AuthenticationToken)
SecurityContextHolder.getContext().getAuthentication();
String clientRegistrationId = auth.getAuthorizedClientRegistrationId();
String principalName = auth.getName();
OAuth2AuthorizedClient client =
clientService.loadAuthorizedClient(clientRegistrationId, principalName); //3 OAuth2AuthorizedClientService可获得用户的OAuth2AuthorizedClient
if (client == null) {
//如果客户端信息使用的是基于内存的InMemoryOAuth2AuthorizedClientService,则重启服务器就会失效,需要重新登录才能恢复,
// 建议使用基于数据库的JdbcOAuth2AuthorizedClientService,本例使用的就是JdbcOAuth2AuthorizedClientService
throw new CustomException(HttpStatus.NOT_ACCEPTABLE, "用户状态异常,请重新登录");
}
String accessToken = client.getAccessToken().getTokenValue(); //4 OAuth2AuthorizedClient可获得用户Access Token
request.getHeaders().add("Authorization", "Bearer " + accessToken); //5 将Access Token通过头部的Bearer Token中访问Resource Server

log.info(String.format("请求地址: %s", request.getURI()));
log.info(String.format("请求头信息: %s", request.getHeaders()));

ClientHttpResponse response = execution.execute(request, body);
log.info(String.format("响应头信息: %s", response.getHeaders()));

return response;
});
restTemplate.setInterceptors(interceptors);
};
}

以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
package com.example.oauth2clientdemo2.controller;

import com.example.oauth2clientdemo2.exception.AjaxResponse;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

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

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

private RestTemplate restTemplate;

public ResController(RestTemplateBuilder restTemplateBuilder) {
this.restTemplate = restTemplateBuilder.build();
}

/**
* 获取资源服务器的数据
*/
@RequestMapping("/res1")
public AjaxResponse getRes(){
return restTemplate.getForObject("http://localhost:8082/res/res1", AjaxResponse.class);
}

@RequestMapping("/user")
public AjaxResponse getUser(){
return restTemplate.postForObject("http://localhost:8082/user",null, AjaxResponse.class);
}

@RequestMapping("/rbac")
public AjaxResponse getRbac(){
return restTemplate.getForObject("http://localhost:8082/rbac", AjaxResponse.class);
}

@RequestMapping("/userInfo")
public Map<String, Object> getuserInfo(){
return restTemplate.getForObject("http://localhost:8082/userInfo", Map.class);
}

}