浅谈微服务安全架构设计

1、 回顾微服务设计理念



浅入微服务架构 一文中,我们了解到什么是微服务,微服务的划分依据,其实,说到底,微服务的设计,有其独到的好处:使得各个模块之间解耦合,让每一个模块有自己独立的灵魂,其他服务即使出现任何问题,自己不会受到任何的影响。这是微服务的核心宗旨。那么今天要讲的微服务安全性问题,其实也是反映微服务的一个核心:高内聚。所谓高内聚,简单的理解就是,对外暴露的最小限度,降低其依赖关系,大部分都作为一个黑盒子封装起来,不直接对外,这样,即使内部发生变更、翻云覆雨,对外的接口没发生改变,这才是好的微服务设计理念,做到完美的对外兼容,一个好的架构设计,首先,这一点可能需要 get 到位,不知道大家咋认为呢?所以今天说的微服务安全性,就跟这个高内聚有一点点相关了。或者说,体现了微服务设计的核心理念。



2、微服务下的各种安全性保证


2.1 常见的几种安全性措施

在微服务中,我们常见的,有如下几种安全性设计的举措:网关设计、服务端口的对外暴露的限度、token 鉴权、OAuth2 的统一认证、微信中的 openId 设计等。这些都是在为服务的安全性作考虑的一些举措。

2.2 OAuth2 的概念

何为 OAuth2 呢?我们先了解 OAuth,Oauth 是一个开放标准,假设有这样一种场景:一个 QQ 应用,希望让一个第三方的(慕课网)应用,能够得到关于自身的一些信息(唯一用户标识,比如说 QQ 号,用户个人信息、一些基础资料,昵称和头像等)。但是在获得这些资料的同时,却又不能提供用户名和密码之类的信息。如下图:

而 OAuth 就是实现上述目标的一种规范。OAuth2 是 OAuth 协议的延续版本,但不兼容 OAuth1.0,即完全废弃了 OAuth1.0。

OAuth2.0 有这么几个术语:客户凭证、令牌、作用域。

客户凭证:客户的 clientId 和密码用于认证客户。

令牌:授权服务器在接收到客户请求后颁发的令牌。

作用域:客户请求访问令牌时,由资源拥有者额外指定的细分权限。

2.3 OAuth2 的原理

在 OAuth2 的授权机制中有 4 个核心对象:

Resource Owner:资源拥有者,即用户。

Client:第三方接入平台、应用,请求者。

Resource Server:资源服务器,存储用户信息、用户的资源信息等资源。

Authorization Server:授权认证服务器。

实现机制:

用户在第三方应用上点击登录,应用向认证服务器发送请求,说有用户希望进行授权操作,同时说明自己是谁、用户授权完成后的回调 url,例如:上面的截图,通过慕课网访问 QQ 获取授权。

认证服务器展示给用户自己的授权界面。

用户进行授权操作,认证服务器验证成功后,生成一个授权编码 code,并跳转到第三方的回调 url。

第三方应用拿到 code 后,连同自己在平台上的身份信息(ID 密码)发送给认证服务器,再一次进行验证请求,说明自己的身份正确,并且用户也已经授权我了,来换取访问用户资源的权限。

认证服务器对请求信息进行验证,如果没问题,就生成访问资源服务器的令牌 access_token,交给第三方应用。

第三方应用使用 access_token 向资源服务器请求资源。

资源服务器验证 access_token 成功后返回响应资源。


2.4 OAuth2 的几种授权模式

OAuth2.0 有这么几个授权模式:授权码模式、简化模式、密码模式、客户端凭证模式。

授权码模式:(authorization_code)是功能最完整、流程最严密的授权模式,code 保证了 token 的安全性,即使 code 被拦截,由于没有 client_secret,也是无法通过 code 获得 token 的。

简化模式:和授权码模式类似,只不过少了获取 code 的步骤,是直接获取令牌 token 的,适用于公开的浏览器单页应用,令牌直接从授权服务器返回,不支持刷新令牌,且没有 code 安全保证,令牌容易因为被拦截窃听而泄露。

密码模式:使用用户名/密码作为授权方式从授权服务器上获取令牌,一般不支持刷新令牌。

客户端凭证模式:一般用于资源服务器是应用的一个后端模块,客户端向认证服务器验证身份来获取令牌。


2.5 实战 OAuth2 的密码模式

本次结合 Spring Cloud Alibaba 组件,实现微服务的安全系统体系,本文主要讲解 OAuth2 的部分。

先来看鉴权中心,鉴权中心需要做到提供单点服务,为所有的客户端微服务的安全保驾护航。下面首先看依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 对redis支持,引入的话项目缓存就支持redis了,所以必须加上redis的相关配置,否则操作相关缓存会报异常 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

如果需要使用 redis 来存储 token,则可以加入 reids 依赖,如果使用 jwt,则使用:

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>

当然,本次的项目模块引入的是比较新的 Spring Boot:

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.13.RELEASE</version>
<relativePath/>
</parent>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<swagger.version>2.6.1</swagger.version>
<xstream.version>1.4.7</xstream.version>
<pageHelper.version>4.1.6</pageHelper.version>
<fastjson.version>1.2.51</fastjson.version>
<springcloud.version>Greenwich.SR3</springcloud.version>
<mysql.version>5.1.46</mysql.version>

<alibaba-cloud.version>2.1.1.RELEASE</alibaba-cloud.version>
<springcloud.alibaba.version>0.9.0.RELEASE</springcloud.alibaba.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${alibaba-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!-- <dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${springcloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency> -->

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${springcloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

剩下的,像数据库、持久化等,其他的可以根据需要添加。

配置完成后,我们需要写一个认证服务器的配置:

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

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

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

import com.damon.component.JwtTokenEnhancer;
import com.damon.login.service.LoginService;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private LoginService loginService;

@Autowired
//@Qualifier("jwtTokenStore")
@Qualifier("redisTokenStore")
private TokenStore tokenStore;
/*@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
private JwtTokenEnhancer jwtTokenEnhancer;*/

@Autowired
private Environment env;


@Autowired
private DataSource dataSource;

@Autowired
private WebResponseExceptionTranslator userOAuth2WebResponseExceptionTranslator;

/**
* redis token 方式
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//验证时发生的情况处理
endpoints.authenticationManager(authenticationManager) //支持 password 模式
.exceptionTranslator(userOAuth2WebResponseExceptionTranslator)//自定义异常处理类添加到认证服务器配置
.userDetailsService(loginService)
.tokenStore(tokenStore);

}

/**
* 客户端配置(给谁发令牌)
* 不同客户端配置不同
*
* authorizedGrantTypes 可以包括如下几种设置中的一种或多种:
authorization_code:授权码类型。需要redirect_uri
implicit:隐式授权类型。需要redirect_uri
password:资源所有者(即用户)密码类型。
client_credentials:客户端凭据(客户端ID以及Key)类型。
refresh_token:通过以上授权获得的刷新令牌来获取新的令牌。

accessTokenValiditySeconds:token 的有效期
scopes:用来限制客户端访问的权限,在换取的 token 的时候会带上 scope 参数,只有在 scopes 定义内的,才可以正常换取 token。
* @param clients
* @throws Exception
* @author Damon
*
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("provider-service")
.secret(passwordEncoder.encode("provider-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2001/login")//授权码模式开启后必须指定


.and()
.withClient("consumer-service")
.secret(passwordEncoder.encode("consumer-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2005/login")//授权码模式开启后必须指定


.and()
.withClient("resource-service")
.secret(passwordEncoder.encode("resource-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2006/login")//授权码模式开启后必须指定

.and()
.withClient("test-sentinel")
.secret(passwordEncoder.encode("test-sentinel-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2008/login")//授权码模式开启后必须指定

.and()
.withClient("test-sentinel-feign")
.secret(passwordEncoder.encode("test-sentinel-feign-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")//配置申请的权限范围
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2010/login")//授权码模式开启后必须指定

.and()
.withClient("customer-service")
.secret(passwordEncoder.encode("customer-service-123"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)//配置刷新token的有效期
.autoApprove(true) //自动授权配置
.scopes("all")
.authorizedGrantTypes("password", "authorization_code", "client_credentials", "refresh_token")//配置授权模式
.redirectUris("http://localhost:2012/login")//授权码模式开启后必须指定
;
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.allowFormAuthenticationForClients();//是允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401
security.checkTokenAccess("isAuthenticated()");//是允许已授权用户访问 checkToken 接口
security.tokenKeyAccess("isAuthenticated()"); // security.tokenKeyAccess("permitAll()");获取密钥需要身份认证,使用单点登录时必须配置,是允许已授权用户获取 token 接口
}
}

Redis 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.damon.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;


@Configuration
public class RedisTokenStoreConfig {

@Autowired
private RedisConnectionFactory redisConnectionFactory;

@Bean
public TokenStore redisTokenStore (){
//return new RedisTokenStore(redisConnectionFactory);
return new MyRedisTokenStore(redisConnectionFactory);
}
}

后面接下来需要配置安全访问的拦截,这时候需要 SpringSecurity:

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

import javax.servlet.http.HttpServletResponse;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

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

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf()
.disable()

.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointHandle())
//.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()

.authorizeRequests()
.antMatchers("/oauth/**", "/login/**")//"/logout/**"
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll();
}

/*@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}*/

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/js/**", "/plugins/**", "/favicon.ico");
}
}

再者,就是需要配置资源拦截:

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;


@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {


@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()

.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointHandle())
//.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()

.requestMatchers().antMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.httpBasic();
}
}

其中,在上面我们配置了资源拦截、权限拦截的统一处理配置:

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.damon.config;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import com.alibaba.fastjson.JSON;
import com.damon.commons.Response;

/**
*
* 统一结果处理
*
* @author Damon
*
*/

public class AuthenticationEntryPointHandle implements AuthenticationEntryPoint {
/**
*
* @author Damon
*
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {

//response.setStatus(HttpServletResponse.SC_FORBIDDEN);
//response.setStatus(HttpStatus.OK.value());

//response.setHeader("Access-Control-Allow-Origin", "*"); //gateway已加,无需再加
//response.setHeader("Access-Control-Allow-Headers", "token");
//解决低危漏洞点击劫持 X-Frame-Options Header未配置
response.setHeader("X-Frame-Options", "SAMEORIGIN");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");

response.getWriter()
.write(JSON.toJSONString(Response.ok(response.getStatus(), -2, authException.getMessage(), null)));
/*response.getWriter()
.write(JSON.toJSONString(Response.ok(200, -2, "Internal Server Error", authException.getMessage())));*/
}
}

最后,自定义异常处理类添加到认证服务器配置:

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

import java.io.IOException;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.common.DefaultThrowableAnalyzer;
import org.springframework.security.oauth2.common.exceptions.InsufficientScopeException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestMethodNotSupportedException;

import com.damon.exception.UserOAuth2Exception;

/**
*
* 自定义异常转换类
* @author Damon
*
*/

@Component("userOAuth2WebResponseExceptionTranslator")
public class UserOAuth2WebResponseExceptionTranslator implements WebResponseExceptionTranslator {
private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();

@Override
public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(e);
Exception ase = (OAuth2Exception)this.throwableAnalyzer.getFirstThrowableOfType(OAuth2Exception.class, causeChain);
//异常链中有OAuth2Exception异常
if (ase != null) {
return this.handleOAuth2Exception((OAuth2Exception)ase);
}
//身份验证相关异常
ase = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (ase != null) {
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.UnauthorizedException(e.getMessage(), e));
}
//异常链中包含拒绝访问异常
ase = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
if (ase instanceof AccessDeniedException) {
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.ForbiddenException(ase.getMessage(), ase));
}
//异常链中包含Http方法请求异常
ase = (HttpRequestMethodNotSupportedException)this.throwableAnalyzer.getFirstThrowableOfType(HttpRequestMethodNotSupportedException.class, causeChain);
if(ase instanceof HttpRequestMethodNotSupportedException){
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.MethodNotAllowed(ase.getMessage(), ase));
}
return this.handleOAuth2Exception(new UserOAuth2WebResponseExceptionTranslator.ServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), e));
}

private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {
int status = e.getHttpErrorCode();
HttpHeaders headers = new HttpHeaders();
headers.set("Cache-Control", "no-store");
headers.set("Pragma", "no-cache");
if (status == HttpStatus.UNAUTHORIZED.value() || e instanceof InsufficientScopeException) {
headers.set("WWW-Authenticate", String.format("%s %s", "Bearer", e.getSummary()));
}
UserOAuth2Exception exception = new UserOAuth2Exception(e.getMessage(),e);
ResponseEntity<OAuth2Exception> response = new ResponseEntity(exception, headers, HttpStatus.valueOf(status));
return response;
}


private static class MethodNotAllowed extends OAuth2Exception {
public MethodNotAllowed(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "method_not_allowed";
}
@Override
public int getHttpErrorCode() {
return 405;
}
}

private static class UnauthorizedException extends OAuth2Exception {
public UnauthorizedException(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "unauthorized";
}
@Override
public int getHttpErrorCode() {
return 401;
}
}

private static class ServerErrorException extends OAuth2Exception {
public ServerErrorException(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "server_error";
}
@Override
public int getHttpErrorCode() {
return 500;
}
}

private static class ForbiddenException extends OAuth2Exception {
public ForbiddenException(String msg, Throwable t) {
super(msg, t);
}
@Override
public String getOAuth2ErrorCode() {
return "access_denied";
}
@Override
public int getHttpErrorCode() {
return 403;
}
}
}

最后,我们可能需要配置一些请求客户端的配置,以及变量配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class BeansConfig {
@Resource
private Environment env;

@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setReadTimeout(env.getProperty("client.http.request.readTimeout", Integer.class, 15000));
requestFactory.setConnectTimeout(env.getProperty("client.http.request.connectTimeout", Integer.class, 3000));
RestTemplate rt = new RestTemplate(requestFactory);
return rt;
}

}
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
package com.damon.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

/**
* 配置信息
* @author Damon
*
*/

@Component
@RefreshScope
public class EnvConfig {

@Value("${jdbc.driverClassName:}")
private String jdbc_driverClassName;

@Value("${jdbc.url:}")
private String jdbc_url;

@Value("${jdbc.username:}")
private String jdbc_username;

@Value("${jdbc.password:}")
private String jdbc_password;

public String getJdbc_driverClassName() {
return jdbc_driverClassName;
}

public void setJdbc_driverClassName(String jdbc_driverClassName) {
this.jdbc_driverClassName = jdbc_driverClassName;
}

public String getJdbc_url() {
return jdbc_url;
}

public void setJdbc_url(String jdbc_url) {
this.jdbc_url = jdbc_url;
}

public String getJdbc_username() {
return jdbc_username;
}

public void setJdbc_username(String jdbc_username) {
this.jdbc_username = jdbc_username;
}

public String getJdbc_password() {
return jdbc_password;
}

public void setJdbc_password(String jdbc_password) {
this.jdbc_password = jdbc_password;
}

}

最后需要配置一些环境配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
application:
name: oauth-cas
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
config:
server-addr: 127.0.0.1:8848
refreshable-dataids: actuator.properties,log.properties

redis: #redis相关配置
database: 8
host: 127.0.0.1 #localhost
port: 6379
password: aaa #有密码时设置
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
timeout: 10000ms

记住:上面这个启动配置需要在 bootstrap 文件中添加,否则,可能会失败,大家可以尝试下。

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
server:
port: 2000
undertow:
uri-encoding: UTF-8
accesslog:
enabled: false
pattern: combined
#这里我们使用了SpringBoot2.x,注意session与1.x不同
servlet:
session:
timeout: PT120M
cookie:
name: OAUTH-CAS-SESSIONID #防止Cookie冲突,冲突会导致登录验证不通过


client:
http:
request:
connectTimeout: 8000
readTimeout: 30000

mybatis:
mapperLocations: classpath:mapper/*.xml
typeAliasesPackage: com.damon.*.model


spring:
profiles:
active: dev

最后,我们添加启动类:

1
2
3
4
5
6
7
8
9
@Configuration
@EnableAutoConfiguration
@ComponentScan(basePackages = {"com.damon"})
@EnableDiscoveryClient
public class CasApp {
public static void main(String[] args) {
SpringApplication.run(CasApp.class, args);
}
}

以上,一个认证中心的代码实战逻辑就完成了。

接下来,我们看一个客户端如何去认证,首先还是依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

在客户端,我们也需要配置一个资源配置与权限配置:

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

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

/**
*
*
* @author Damon
*
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()

.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPointHandle())
//.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.and()

.requestMatchers().antMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.httpBasic();
}
}

当然,权限拦截可能就相对简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.damon.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
*
* 在接口上配置权限时使用
* @author Damon
*
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Order(101)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

同样,这里也需要一个统一结果处理类,这里就不展示了。

接下来,我们主要看配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cas-server-url: http://oauth-cas #http://localhost:2000#设置可以访问的地址

security:
oauth2: #与cas对应的配置
client:
client-id: provider-service
client-secret: provider-service-123
user-authorization-uri: ${cas-server-url}/oauth/authorize #是授权码认证方式需要的
access-token-uri: ${cas-server-url}/oauth/token #是密码模式需要用到的获取 token 的接口
resource:
loadBalanced: true
#jwt: #jwt存储token时开启
#key-uri: ${cas-server-url}/oauth/token_key
#key-value: test_jwt_sign_key
id: provider-service
#指定用户信息地址
user-info-uri: ${cas-server-url}/api/user #指定user info的URI,原生地址后缀为/auth/user
prefer-token-info: false
#token-info-uri:
authorization:
check-token-access: ${cas-server-url}/oauth/check_token #当此web服务端接收到来自UI客户端的请求后,需要拿着请求中的 token 到认证服务端做 token 验证,就是请求的这个接口

在上面的配置里,我们看到了各种注释了,讲得很仔细,但是我要强调下:为了高可用,我们的认证中心可能多个,所以需要域名来作 LB。同时,开启了 loadBalanced=true。最后,如果是授权码认证模式,则需要 “user-authorization-uri”,如果是密码模式,需要 “access-token-uri” 来获取 token。我们通过它 “user-info-uri” 来获取认证中心的用户信息,从而判断该用户的权限,从而访问相应的资源。另外,上面的配置需要在 bootstrap 文件中,否则可能失败,大家可以试试。

接下来,我们添加一般配置:

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
server:
port: 2001
undertow:
uri-encoding: UTF-8
accesslog:
enabled: false
pattern: combined
servlet:
session:
timeout: PT120M
cookie:
name: PROVIDER-SERVICE-SESSIONID #防止Cookie冲突,冲突会导致登录验证不通过


backend:
ribbon:
client:
enabled: true
ServerListRefreshInterval: 5000

ribbon:
ConnectTimeout: 3000
# 设置全局默认的ribbon的读超时
ReadTimeout: 1000
eager-load:
enabled: true
clients: oauth-cas,consumer-service
MaxAutoRetries: 1 #对第一次请求的服务的重试次数
MaxAutoRetriesNextServer: 1 #要重试的下一个服务的最大数量(不包括第一个服务)
#listOfServers: localhost:5556,localhost:5557
#ServerListRefreshInterval: 2000
OkToRetryOnAllOperations: true
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule


hystrix.command.BackendCall.execution.isolation.thread.timeoutInMilliseconds: 5000
hystrix.threadpool.BackendCallThread.coreSize: 5

这里,我们使用了 Ribbon 来做 LB,hystrix 来作熔断,最后需要注意的是:加上了 cookie name,防止 Cookie 冲突,冲突会导致登录验证不通过。

配置启动类:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@EnableAutoConfiguration
@ComponentScan(basePackages = {"com.damon"})
@EnableDiscoveryClient
@EnableOAuth2Sso
public class ProviderApp {

public static void main(String[] args) {
SpringApplication.run(ProviderApp.class, args);
}

}

我们在上面配置了所有带有 “/api/**“ 的路径请求,都会加以拦截,根据用户的信息来判断其是否有权限访问。

写一个简单的测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api/user")
public class UserController {

private static final Logger logger = LoggerFactory.getLogger(UserController.class);

@Autowired
private UserService userService;

@PreAuthorize("hasAuthority('admin')")
@GetMapping("/auth/admin")
public Object adminAuth() {
logger.info("test password mode");
return "Has admin auth!";
}
}

上面的代码表示:如果用户具有 “admin” 的权限,则能够访问该接口,否则会被拒绝。

本文用的是 alibaba 的组件来作 LB,具体可以看前面的文章,用域名来找到服务。同时也加上了网关 Gateway。

最后,我们先来通过密码模式来进行认证吧:

1
curl -i -X POST -d "username=admin&password=123456&grant_type=password&client_id=provider-service&client_secret=provider-service-123" http://localhost:5555/oauth-cas/oauth/token

认证成功后,会返回如下结果:

1
{"access_token":"d2066f68-665b-4038-9dbe-5dd1035e75a0","token_type":"bearer","refresh_token":"44009836-731c-4e6a-9cc3-274ce3af8c6b","expires_in":3599,"scope":"all"}

接下来,我们通过 token 来访问接口:

1
curl -i -H "Accept: application/json" -H "Authorization:bearer d2066f68-665b-4038-9dbe-5dd1035e75a0" -X GET http://localhost:5555/provider-service/api/user/auth/admin

成功会返回结果:

1
Has admin auth!

token 如果失效,会返回:

1
{"error":"invalid_token","error_description":"d2066f68-665b-4038-9dbe-5dd1035e75a01"}



3、GitHub 的授权应用案例



如果你的应用想要接入 GitHub,则可以通过如下办法来实现。

首先注册一个 GitHub 账号,登陆后,找到设置,打开页面,最下面有一个开发者设置:

找到后,点击,可以看到三个,可以选择第二个方式来接入:

可以新增你的应用 app,新建时,应用名、回调地址必填项:

最后,完成后会生成一个 Client ID、Client Secret。

然后利用 Github 官方给的文档来进行认证、接入,授权逻辑:

1.在注册完信息后生成了 Client ID、Client Secret,首先,用户点击 github 登录本地应用引导用户跳转到第三方授权页跳转地址:

1
https://github.com/login/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&state={state}

其中,client_id,client_secret 是注册好 Oauth APP 后 github 提供的,需要写在本地代码或者配置文件中,state 也是在本地生成的。redirect_uri 就是在 GitHub 官网填的 Authorization callback URL。此时带着 state 等参数去申请授权,但此时尚未登陆,未能通过 authorize,GitHub 返回 code 参数。

2.授权成功后会重定向带参数访问上面的 redirect_uri,并多了一个 code 参数
后台接收 code 这个参数,我们带着这个 code 再次访问 github 地址:

1
https://github.com/login/oauth/access_token?client_id=xxx&client_secret=xxx&code=xxx&redirect_uri=http://localhost:3001/authCallback

注意:上面的 redirect_uri 要与之前在新建 app 时填写的保持一直,否则会报错。

3.通过 state 参数和 code 参数,成功获取 access_token
有了 access_token,只需要把 access_token 参数放在 URL 后面即可,就可以换取用户信息了。访问地址:

1
https://api.github.com/user?access_token=xxx

4.得到 GitHub 授权用户的个人信息,就表明授权成功。




4、微服务安全架构设计



在微服务中,安全性是一个很重要的问题。我们经常比较多的场景是:服务 A 需要调用服务 B,但是问题来了,到底是走外网调用呢?还是走局域网调用呢?这当然看 A、B 是否在同一个网段,如果在同一个局域网段,那肯定走局域网好。为什么呢?因为局域网快呀,如果说还有理由吗?当然有:除了网络快,降低网络开销,还可以保证安全性,不至于被黑客黑掉。这是安全的一个保证。

那么除了上面说的安全性,还有其他的吗?比如:在一个局域网下,有 N 个微服务模块,但是这些微服务并不想完全直接暴露给外部,这时候,就需要一个网关 Gateway 来处理。网关把所有的服务给路由了,就像在所有的服务上面一层,加了一个保护光环,突出高内聚的含义。同时还可以加上一些拦截,安全的拦截,鉴权、认证等。存在通过 token 的鉴权,也可以通过 jwt 的,等等。有时候,可以借助 redis 通过 session 共享。也可以通过 OAuth2 的鉴权模式来实现安全拦截。

最后安全性的考虑是在每个服务的接口设计上,比如:幂等的存在,让很多恶意攻击成为无用之功。更多的介绍可以看下面:

1
https://mp.weixin.qq.com/s/G3yhwvLVTu_T5uPxgZD00w

结束福利

开源实战利用 k8s 作微服务的架构设计代码:

1
2
3
https://gitee.com/damon_one/spring-cloud-k8s
https://gitee.com/damon_one/spring-cloud-oauth2
https://gitee.com/damon_one/Springcloud-Learning-Dalston

欢迎大家 star,多多指教。


关于作者

  笔名:Damon,技术爱好者,长期从事 Java 开发、Spring Cloud 的微服务架构设计,以及结合 Docker、K8s 做微服务容器化,自动化部署等一站式项目部署、落地。目前主要从事基于 K8s 云原生架构研发的工作。Golang 语言开发,长期研究边缘计算框架 KubeEdge、调度框架 Volcano 等。公众号 交个朋友之猿天地 发起人。个人微信 DamonStatham,星球:《交个朋友之猿田地》,个人网站:交个朋友之猿天地 | 微服务 | 容器化 | 自动化,欢迎來撩。


欢迎关注:InfoQ

欢迎关注:腾讯自媒体专栏


欢迎关注

公号:交个朋友之猿天地

公号:damon8

公号:天山六路折梅手


打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2023 交个朋友之猿天地
  • Powered By Hexo | Title - Nothing
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信