0%

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);
}

}
---------------- The End ----------------

欢迎关注我的其它发布渠道