Spring Boot Security 整合 OAuth2 设计安全API接口服务

简介

OAuth是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是2.0版。本文重点讲解Spring Boot项目对OAuth2进行的实现,如果你对OAuth2不是很了解,你可以先理解 OAuth 2.0 - 阮一峰,这是一篇对于oauth2很好的科普文章。

OAuth2概述

oauth2根据使用场景不同,分成了4种模式

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

在项目中我们通常使用授权码模式,也是四种模式中最复杂的,通常网站中经常出现的微博,qq第三方登录,都会采用这个形式。

Oauth2授权主要由两部分组成:

  • Authorization server:认证服务
  • Resource server:资源服务

在实际项目中以上两个服务可以在一个服务器上,也可以分开部署。下面结合spring boot来说明如何使用。

快速上手

之前的文章已经对 Spring Security 进行了讲解,这一节对涉及到 Spring Security 的配置不详细讲解。若不了解 Spring Security 先移步到 Spring Boot Security 详解

建表

客户端信息可以存储在内存、redis和数据库。在实际项目中通常使用redis和数据库存储。本文采用数据库。Spring 0Auth2 己经设计好了数据库的表,且不可变。表及字段说明参照:Oauth2数据库表说明

创建0Auth2数据库的脚本如下:

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
DROP TABLE IF EXISTS `clientdetails`;
DROP TABLE IF EXISTS `oauth_access_token`;
DROP TABLE IF EXISTS `oauth_approvals`;
DROP TABLE IF EXISTS `oauth_client_details`;
DROP TABLE IF EXISTS `oauth_client_token`;
DROP TABLE IF EXISTS `oauth_refresh_token`;

CREATE TABLE `clientdetails` (
`appId` varchar(128) NOT NULL,
`resourceIds` varchar(256) DEFAULT NULL,
`appSecret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`grantTypes` varchar(256) DEFAULT NULL,
`redirectUrl` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additionalInformation` varchar(4096) DEFAULT NULL,
`autoApproveScopes` varchar(256) DEFAULT NULL,
PRIMARY KEY (`appId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


CREATE TABLE `oauth_access_token` (
`token_id` varchar(256) DEFAULT NULL,
`token` blob,
`authentication_id` varchar(128) NOT NULL,
`user_name` varchar(256) DEFAULT NULL,
`client_id` varchar(256) DEFAULT NULL,
`authentication` blob,
`refresh_token` varchar(256) DEFAULT NULL,
PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_approvals` (
`userId` varchar(256) DEFAULT NULL,
`clientId` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`status` varchar(10) DEFAULT NULL,
`expiresAt` datetime DEFAULT NULL,
`lastModifiedAt` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_client_details` (
`client_id` varchar(128) NOT NULL,
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`authorized_grant_types` varchar(256) DEFAULT NULL,
`web_server_redirect_uri` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_client_token` (
`token_id` varchar(256) DEFAULT NULL,
`token` blob,
`authentication_id` varchar(128) NOT NULL,
`user_name` varchar(256) DEFAULT NULL,
`client_id` varchar(256) DEFAULT NULL,
PRIMARY KEY (`authentication_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
`code` varchar(256) DEFAULT NULL,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `oauth_refresh_token` (
`token_id` varchar(256) DEFAULT NULL,
`token` blob,
`authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

为了测试方便,我们先插入一条客户端信息。

1
INSERT INTO `oauth_client_details` VALUES ('dev', '', 'dev', 'app', 'password,client_credentials,authorization_code,refresh_token', 'http://www.baidu.com', '', 3600, 3600, '{\"country\":\"CN\",\"country_code\":\"086\"}', 'false');

用户、权限、角色用到的表如下:

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
DROP TABLE IF EXISTS `user`;
DROP TABLE IF EXISTS `role`;
DROP TABLE IF EXISTS `user_role`;
DROP TABLE IF EXISTS `role_permission`;
DROP TABLE IF EXISTS `permission`;

CREATE TABLE `user` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `role` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `user_role` (
`user_id` bigint(11) NOT NULL,
`role_id` bigint(11) NOT NULL
);
CREATE TABLE `role_permission` (
`role_id` bigint(11) NOT NULL,
`permission_id` bigint(11) NOT NULL
);
CREATE TABLE `permission` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`url` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`description` varchar(255) NULL,
`pid` bigint(11) NOT NULL,
PRIMARY KEY (`id`)
);

INSERT INTO user (id, username, password) VALUES (1,'user','e10adc3949ba59abbe56e057f20f883e');
INSERT INTO user (id, username , password) VALUES (2,'admin','e10adc3949ba59abbe56e057f20f883e');
INSERT INTO role (id, name) VALUES (1,'USER');
INSERT INTO role (id, name) VALUES (2,'ADMIN');
INSERT INTO permission (id, url, name, pid) VALUES (1,'/**','',0);
INSERT INTO permission (id, url, name, pid) VALUES (2,'/**','',0);
INSERT INTO user_role (user_id, role_id) VALUES (1, 1);
INSERT INTO user_role (user_id, role_id) VALUES (2, 2);
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1);
INSERT INTO role_permission (role_id, permission_id) VALUES (2, 2);

项目结构

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
resources
|____templates
| |____login.html
| |____application.yml
java
|____com
| |____gf
| | |____SpringbootSecurityApplication.java
| | |____config
| | | |____SecurityConfig.java
| | | |____MyFilterSecurityInterceptor.java
| | | |____MyInvocationSecurityMetadataSourceService.java
| | | |____ResourceServerConfig.java
| | | |____WebResponseExceptionTranslateConfig.java
| | | |____AuthorizationServerConfiguration.java
| | | |____MyAccessDecisionManager.java
| | |____entity
| | | |____User.java
| | | |____RolePermisson.java
| | | |____Role.java
| | |____mapper
| | | |____PermissionMapper.java
| | | |____UserMapper.java
| | | |____RoleMapper.java
| | |____controller
| | | |____HelloController.java
| | | |____MainController.java
| | |____service
| | | |____MyUserDetailsService.java

关键代码

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</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-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>

SecurityConfig

支持password模式要配置AuthenticationManager

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
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private MyUserDetailsService userService;


@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

//校验用户
auth.userDetailsService( userService ).passwordEncoder( new PasswordEncoder() {
//对密码进行加密
@Override
public String encode(CharSequence charSequence) {
System.out.println(charSequence.toString());
return DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
}
//对密码进行判断匹配
@Override
public boolean matches(CharSequence charSequence, String s) {
String encode = DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
boolean res = s.equals( encode );
return res;
}
} );

}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.requestMatchers()
.antMatchers("/oauth/**","/login","/login-error")
.and()
.authorizeRequests()
.antMatchers("/oauth/**").authenticated()
.and()
.formLogin().loginPage( "/login" ).failureUrl( "/login-error" );
}


@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception{
return super.authenticationManager();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}

@Override
public boolean matches(CharSequence charSequence, String s) {
return Objects.equals(charSequence.toString(),s);
}
};
}


}

AuthorizationServerConfiguration 认证服务器配置

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
/**
* 认证服务器配置
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {


/**
* 注入权限验证控制器 来支持 password grant type
*/
@Autowired
private AuthenticationManager authenticationManager;

/**
* 注入userDetailsService,开启refresh_token需要用到
*/
@Autowired
private MyUserDetailsService userDetailsService;

/**
* 数据源
*/
@Autowired
private DataSource dataSource;

/**
* 设置保存token的方式,一共有五种,这里采用数据库的方式
*/
@Autowired
private TokenStore tokenStore;

@Autowired
private WebResponseExceptionTranslator webResponseExceptionTranslator;

@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore( dataSource );
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
/**
* 配置oauth2服务跨域
*/
CorsConfigurationSource source = new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedOrigin(request.getHeader( HttpHeaders.ORIGIN));
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setMaxAge(3600L);
return corsConfiguration;
}
};

security.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients()
.addTokenEndpointAuthenticationFilter(new CorsFilter(source));
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//开启密码授权类型
endpoints.authenticationManager(authenticationManager);
//配置token存储方式
endpoints.tokenStore(tokenStore);
//自定义登录或者鉴权失败时的返回信息
endpoints.exceptionTranslator(webResponseExceptionTranslator);
//要使用refresh_token的话,需要额外配置userDetailsService
endpoints.userDetailsService( userDetailsService );

}


}

ResourceServerConfig 资源服务器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 资源提供端的配置
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

/**
* 这里设置需要token验证的url
* 可以在WebSecurityConfigurerAdapter中排除掉,
* 对于相同的url,如果二者都配置了验证
* 则优先进入ResourceServerConfigurerAdapter,进行token验证。而不会进行
* WebSecurityConfigurerAdapter 的 basic auth或表单认证。
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.requestMatchers().antMatchers("/hi")
.and()
.authorizeRequests()
.antMatchers("/hi").authenticated();
}


}

关键代码就是这些,其他类代码参照后面提供的源码地址。

验证

密码授权模式

[ 密码模式需要参数:username , password , grant_type , client_id , client_secret ]

请求token

1
curl -X POST -d "username=admin&password=123456&grant_type=password&client_id=dev&client_secret=dev" http://localhost:8080/oauth/token

返回

1
2
3
4
5
6
7
{
"access_token": "d94ec0aa-47ee-4578-b4a0-8cf47f0e8639",
"token_type": "bearer",
"refresh_token": "23503bc7-4494-4795-a047-98db75053374",
"expires_in": 3475,
"scope": "app"
}

不携带token访问资源,

1
curl http://localhost:8080/hi\?name\=zhangsan

返回提示未授权

1
2
3
4
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}

携带token访问资源

1
curl http://localhost:8080/hi\?name\=zhangsan\&access_token\=164471f7-6fc6-4890-b5d2-eb43bda3328a

返回正确

1
hi , zhangsan

刷新token

1
curl  -X POST -d 'grant_type=refresh_token&refresh_token=23503bc7-4494-4795-a047-98db75053374&client_id=dev&client_secret=dev' http://localhost:8080/oauth/token

返回

1
2
3
4
5
6
7
{
"access_token": "ef53eb01-eb9b-46d8-bd58-7a0f9f44e30b",
"token_type": "bearer",
"refresh_token": "23503bc7-4494-4795-a047-98db75053374",
"expires_in": 3599,
"scope": "app"
}

客户端授权模式

[ 客户端模式需要参数:grant_type , client_id , client_secret ]

请求token

1
curl -X POST -d "grant_type=client_credentials&client_id=dev&client_secret=dev" http://localhost:8080/oauth/token

返回

1
2
3
4
5
6
{
"access_token": "a7be47b3-9dc8-473e-967a-c7267682dc66",
"token_type": "bearer",
"expires_in": 3564,
"scope": "app"
}

授权码模式

获取code

浏览器中访问如下地址:

1
http://localhost:8080/oauth/authorize?response_type=code&client_id=dev&redirect_uri=http://www.baidu.com

跳转到登录页面,输入账号和密码进行认证:

认证后会跳转到授权确认页面(oauth_client_details 表中 “autoapprove” 字段设置为true 时,不会出授权确认页面):

确认后,会跳转到百度,并且地址栏中会带上我们想得到的code参数:

通过code换token

1
curl -X POST -d "grant_type=authorization_code&code=qS03iu&client_id=dev&client_secret=dev&redirect_uri=http://www.baidu.com" http://localhost:8080/oauth/token

返回

1
2
3
4
5
6
7
{
"access_token": "90a246fa-a9ee-4117-8401-ca9c869c5be9",
"token_type": "bearer",
"refresh_token": "23503bc7-4494-4795-a047-98db75053374",
"expires_in": 3319,
"scope": "app"
}

参考

https://segmentfault.com/a/1190000012260914

https://stackoverflow.com/questions/28537181/spring-security-oauth2-which-decides-security

源码

https://github.com/gf-huanchupk/SpringBootLearning/tree/master/springboot-security-oauth2