分布式系统认证方案

什么是分布式系统

随着软件环境和需求的变化 ,软件的架构由单体结构演变为分布式架构,具有分布式架构的系统叫分布式系统,分布式系统的运行通常依赖网络,它将单体结构的系统分为若干服务,服务之间通过网络交互来完成用户的业务处
理,当前流行的微服务架构就是分布式系统架构,如下图:

image-20221124201044322

分布式认证需求

分布式系统的每个服务都会有认证、授权的需求,如果每个服务都实现一套认证授权逻辑会非常冗余,考虑分布式系统共享性的特点,需要由独立的认证服务处理系统认证授权的请求;考虑分布式系统开放性的特点,不仅对系统内部服务提供认证,对第三方系统也要提供认证。分布式认证的需求总结如下:
统一认证授权

  • 统一认证授权:提供独立的认证服务,统一处理认证授权。
  • 应用接入认证:应提供扩展和开放能力,提供安全的系统对接机制,并可开放部分API给接入第三方使用,一方应用(内部 系统服务)和三方应用(第三方应用)均采用统一机制接入。

技术方案

根据 选型的分析,决定采用基于token的认证方式,它的优点是:

  1. 适合统一认证的机制,客户端、一方应用、三方应用都遵循一致的认证机制。

  2. token认证方式对第三方应用接入更适合,因为它更开放,可使用当前有流行的开放协议Oauth2.0、JWT等。

  3. 一般情况服务端无需存储会话信息,减轻了服务端的压力。

分布式系统认证技术方案见下图:

image-20221124201452492

系统大致流程如下:

  1. 用户通过接入方(应用)登录,接入方采取OAuth2.0方式在统一认证服务(UAA)中认证。
  2. 认证服务(UAA)调用验证该用户的身份是否合法,并获取用户权限信息。
  3. 认证服务(UAA)获取接入方权限信息,并验证接入方是否合法。
  4. 若登录用户以及接入方都合法,认证服务生成jwt令牌返回给接入方,其中jwt中包含了用户权限及接入方权限。
  5. 后续,接入方携带jwt令牌对API网关内的微服务资源进行访问。
  6. API网关对令牌解析、并验证接入方的权限是否能够访问本次请求的微服务。
  7. 如果接入方的权限没问题,API网关将原请求header中附加解析后的明文Token,并将请求转发至微服务。
  8. 微服务收到请求,明文token中包含登录用户的身份和权限信息。因此后续微服务自己可以干两件事:
    • 用户授权拦截(看当前用户是否有权访问该资源)
    • 将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)

OAuth2.0

OAuth(开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不
需要将用户名和密码提供给第三方应用或分享他们数据的所有内容。OAuth2.0是OAuth协议的延续版本,但不向
后兼容OAuth 1.0即完全废止了OAuth1.0。很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服
务,这些都足以说明OAUTH标准逐渐成为开放资源授权的标准。

OAauth2.0认证流程:

image-20221124201956413

OAauth2.0包括以下角色:

  1. 客户端:
    本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android客户端、Web客户端(浏览器端)、微信客户端等。

  2. 资源拥有者:
    通常为用户,也可以是应用程序,即该资源的拥有者。

  3. 授权服务器(也称认证服务器):

    用于服务提供商对资源拥有的身份进行认证、对访问资源进行授权,认证成功后会给客户端发放令牌
    (access_token),作为客户端访问资源服务器的凭据。本例为微信的认证服务器。

  4. 资源服务器:存储资源的服务器。

Spring Cloud Security OAuth2

相关概念

Spring-Security-OAuth2是对OAuth2的一种实现,并且跟我们之前学习的Spring Security相辅相成,与Spring
Cloud体系的集成也非常便利,接下来,我们需要对它进行学习,最终使用它来实现我们设计的分布式认证授权解
决方案。
OAuth2.0的服务提供方涵盖两个服务,即授权服务 (Authorization Server,也叫认证服务) 和资源服务 (Resource
Server),使用 Spring Security OAuth2 的时候你可以选择把它们在同一个应用程序中实现,也可以选择建立使用
同一个授权服务的多个资源服务。

授权服务 (Authorization Server)应包含对接入端以及登入用户的合法性进行验证并颁发token等功能,对令牌
的请求端点由 Spring MVC 控制器进行实现,下面是配置一个认证服务必须要实现的endpoints:

  • AuthorizationEndpoint 服务于认证请求。默认 URL: /oauth/authorize 。

  • TokenEndpoint 服务于访问令牌的请求。默认 URL: /oauth/token 。

**资源服务 (Resource Server)**应包含对资源的保护功能,对非法请求进行拦截,对请求中token进行解析鉴
权等,下面的过滤器用于实现 OAuth 2.0 资源服务:

  • OAuth2AuthenticationProcessingFilter用来对请求给出的身份令牌解析鉴权。

本教程分别创建uaa授权服务(也可叫认证服务)和order订单资源服务。

认证流程如下:

  1. 客户端请求UAA授权服务进行认证。
  2. 认证通过后由UAA颁发令牌。
  3. 客户端携带令牌Token请求资源服务。
  4. 资源服务校验令牌的合法性,合法即返回资源信息。

授权模式

授权码模式

下图是授权码模式交互图:

image-20221126192301196

(1)资源拥有者打开客户端,客户端要求资源拥有者给予授权,它将浏览器被重定向到授权服务器,重定向时会
附加客户端的身份信息。如:http://localhost:53020/uaa/oauth/authorize? client_id=c1&response_type=code&scope=all&redirect_uri=http://www.baidu.com

(2)浏览器出现向授权服务器授权页面,之后将用户同意授权。
(3)授权服务器将授权码(AuthorizationCode)转经浏览器发送给client(通过redirect_uri)。
(4)客户端拿着授权码向授权服务器索要访问access_token,请求如下:/uaa/oauth/token? client_id=c1&client_secret=secret&grant_type=authorization_code&code=5PgfcD&redirect_uri=http://www.baidu.com

(5)授权服务器返回令牌(access_token)

请求参数说明:

  • client_id:客户端准入标识。
  • client_secret:客户端秘钥。
  • grant_type:授权类型,填写authorization_code,表示授权码模式
  • code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
  • redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。

这种模式是四种模式中最安全的一种模式。一般用于client是Web服务器端应用或第三方的原生App调用资源服务
的时候。因为在这种模式中access_token不会经过浏览器或移动端的App,而是直接从服务端去交换,这样就最大
限度的减小了令牌泄漏的风险。

简化模式

下图是简化模式交互图:

image-20221126195637438

(1)资源拥有者打开客户端,客户端要求资源拥有者给予授权,它将浏览器被重定向到授权服务器,重定向时会
附加客户端的身份信息。如:/uaa/oauth/authorize?client_id=c1&response_type=token&scope=all&redirect_uri=http://www.baidu.com

(2)浏览器出现向授权服务器授权页面,之后将用户同意授权。
(3)授权服务器将授权码将令牌(access_token)以Hash的形式存放在重定向uri的fargment中发送给浏览
器。

一般来说,简化模式用于没有服务器端的第三方单页面应用,因为没有服务器端就无法接收授权码。

密码模式

下图是密码模式交互图:

image-20221126195737441

(1)资源拥有者将用户名、密码发送给客户端
(2)客户端拿着资源拥有者的用户名、密码向授权服务器请求令牌(access_token),请求如下:/uaa/oauth/token? client_id=c1&client_secret=secret&grant_type=password&username=shangsan&password=123

(3)授权服务器将令牌(access_token)发送给client

这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我
们自己开发的情况下。因此密码模式一般用于我们自己开发的,第一方原生App或第一方单页面应用。

客户端模式

下图是客户端模式交互图:

image-20221126200038251

(1)客户端向授权服务器发送自己的身份信息,并请求令牌(access_token)
(2)确认客户端身份无误后,将令牌(access_token)发送给client,请求如下:/uaa/oauth/token?client_id=c1&client_secret=secret&grant_type=client_credentials

这种模式是最方便但最不安全的模式。因此这就要求我们对client完全的信任,而client本身也是安全的。因
此这种模式一般用来提供给我们完全信任的服务器端服务。比如,合作方系统对接,拉取一组用户信息。

环境搭建

我们理想的解决方案应该是这样的,认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。

相关服务划分:

  • oauth2-gateway:网关服务,负责请求转发和鉴权功能,整合Spring Security+Oauth2;
  • oauth2-auth:Oauth2认证服务,负责对登录用户进行认证,整合Spring Security+Oauth2;
  • oauth2-api:受保护的API服务,用户鉴权通过后可以访问该服务,不整合Spring Security+Oauth2。

oauth2-auth

Oauth2认证服务,负责对登录用户进行认证,整合Spring Security+Oauth2;

依赖导入:

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>


<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!--mybatisPlus依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!--mysql数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- 注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<!-- web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--Redis依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--fastjson依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>
<!--jwt依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.3</version>
</dependency>
<!-- actuator-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

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
server:
port: 8003

spring:
application:
name: securityTest
cloud:
nacos:
discovery:
server-addr: localhost:8848
datasource:
url: jdbc:mysql://localhost:3306/demo_security?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
database: 0
host: 127.0.0.1 #Redis服务器地址
lettuce:
pool:
max-active: 20
max-idle: 5
max-wait: -1
min-idle: 0
port: 6379
timeout: 1800000

management:
endpoints:
web:
exposure:
include: "*"

创建UserServiceImpl

实现Spring Security的UserDetailsService接口,用于加载用户信息;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

@Autowired
private UserMapper userMapper;

@Autowired
private MenuMapper menuMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName,username));
//如果查询不到数据就通过抛出异常来给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//封装成UserDetails对象返回
return new LoginUser(user,menuMapper.selectPermsByUserId(user.getId()));
}
}

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

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

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

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

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.anyRequest().authenticated()
.and()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf().disable();
http.cors();
}
}

配置使用JWT存储令牌:

把令牌存储在内存中的,这样如果部署多个服务,就会导致无法使用令牌的问题。 Spring Cloud Security中有两种存储令牌的方式可用于解决该问题,一种是使用Redis来存储,另一种是使用JWT来存储。

  • 添加使用JWT存储令牌的配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Configuration
    public class TokenConfig {
    @Bean
    public TokenStore jwtTokenStore() {
    return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
    JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
    //配置JWT使用的秘钥
    accessTokenConverter.setSigningKey("wht");
    return accessTokenConverter;
    }

    /**
    * token 增强器
    */
    @Bean
    public JwtTokenEnhancer jwtTokenEnhancer() {
    return new JwtTokenEnhancer();
    }
    }
  • 在认证服务器配置中指定令牌的存储策略为JWT:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * 配置令牌访问管理
    * @param endpoints
    */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints
    // 密码模式需要
    .authenticationManager(authenticationManager)
    // 用户信息
    .userDetailsService(userDetailsService)
    // 配置令牌存储策略
    .tokenStore(tokenStore)
    .accessTokenConverter(accessTokenConverter)
    // 允许post提交
    .allowedTokenEndpointRequestMethods(HttpMethod.POST);
    }
  • 对token进行增强:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Component
    public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    User user = loginUser.getUser();
    Map<String, Object> info = new HashMap<>();
    // 把用户ID设置到JWT中
    info.put("uid", user.getId());
    ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
    return accessToken;
    }
    }

配置认证服务器

接下来进行以下的配置:

  • ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在
    这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息。
  • AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token
    services)。
  • AuthorizationServerSecurityConfigurer:用来配置令牌端点的安全约束.
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
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

@Autowired
PasswordEncoder passwordEncoder;

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private UserDetailsServiceImpl userDetailsService;

@Autowired
private ClientDetailsService clientDetailsService;

@Autowired
@Qualifier("jwtTokenStore")
private TokenStore tokenStore;


@Autowired
@Qualifier("jwtAccessTokenConverter")
private JwtAccessTokenConverter accessTokenConverter;

@Autowired
private JwtTokenEnhancer jwtTokenEnhancer;



/**
* 用来配置客户端详情服务
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 自动从数据库查询出来进行校验
clients.withClientDetails(clientDetailsService);
}

/**
* 配置从数据库查询客户端信息
* @param dataSource
* @return
*/
@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {
ClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
((JdbcClientDetailsService) clientDetailsService).setPasswordEncoder(passwordEncoder);
return clientDetailsService;
}



/**
* 配置令牌访问管理
* @param endpoints
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
// 添加增强器
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer,accessTokenConverter));
endpoints
// 密码模式需要
.authenticationManager(authenticationManager)
// 用户信息
.userDetailsService(userDetailsService)
// 配置令牌存储策略
.tokenStore(tokenStore)
.accessTokenConverter(accessTokenConverter)
.tokenEnhancer(tokenEnhancerChain)
// 允许post提交
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}


/**
* 令牌端点的安全约束配置
* @param security
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security){
security
// 允许公钥获取端点
.tokenKeyAccess("permitAll()")
// 允许检查token
.checkTokenAccess("permitAll()")
// 允许表单认证
.allowFormAuthenticationForClients();
}
}

管理令牌:

AuthorizationServerTokenServices 接口定义了一些操作使得你可以对令牌进行一些必要的管理,令牌可以被用来加载身份信息,里面包含了这个令牌的相关权限。

自己可以创建 AuthorizationServerTokenServices 这个接口的实现,则需要继承 DefaultTokenServices 这个类,里面包含了一些有用实现,你可以使用它来修改令牌的格式和令牌的存储。默认的,当它尝试创建一个令牌的时候,是使用随机值来进行填充的,除了持久化令牌是委托一个 TokenStore 接口来实现以外,这个类几乎帮你做了所有的事情。并且 TokenStore 这个接口有一个默认的实现,它就是 InMemoryTokenStore ,如其命名,所有的令牌是被保存在了内存中。除了使用这个类以外,你还可以使用一些其他的预定义实现,下面有几个版本,它们都实现了TokenStore接口:

  • InMemoryTokenStore:这个版本的实现是被默认采用的,它可以完美的工作在单服务器上(即访问并发量压力不大的情况下,并且它在失败的时候不会进行备份),大多数的项目都可以使用这个版本的实现来进行尝试,你可以在开发的时候使用它来进行管理,因为不会被保存到磁盘中,所以更易于调试。
  • JdbcTokenStore:这是一个基于JDBC的实现版本,令牌会被保存进关系型数据库。使用这个版本的实现时,你可以在不同的服务器之间共享令牌信息,使用这个版本的时候请注意把”spring-jdbc”这个依赖加入到你的classpath当中。
  • JwtTokenStore:这个版本的全称是 JSON Web Token(JWT),它可以把令牌相关的数据进行编码(因此对于后端服务来说,它不需要进行存储,这将是一个重大优势),但是它有一个缺点,那就是撤销一个已经授权令牌将会非常困难,所以它通常用来处理一个生命周期较短的令牌以及撤销刷新令牌(refresh_token)。另外一个缺点就是这个令牌占用的空间会比较大,如果你加入了比较多用户凭证信息。JwtTokenStore 不会保存任何数据,但是它在转换令牌值以及授权信息方面与 DefaultTokenServices 所扮演的角色是一样的。

配置授权类型(Grant Types)

AuthorizationServerEndpointsConfigurer 这个对象的实例可以完成令牌服务以及令牌endpoint配置。

AuthorizationServerEndpointsConfigurer 通过设定以下属性决定支持的授权类型(Grant Types):

  • authenticationManager:认证管理器,当你选择了资源所有者密码(password)授权类型的时候,请设置这个属性注入一个 AuthenticationManager 对象。
  • userDetailsService:如果你设置了这个属性的话,那说明你有一个自己的 UserDetailsService 接口的实现,或者你可以把这个东西设置到全局域上面去(例如 GlobalAuthenticationManagerConfigurer 这个配置对象),当你设置了这个之后,那么 “refresh_token” 即刷新令牌授权类型模式的流程中就会包含一个检查,用来确保这个账号是否仍然有效,假如说你禁用了这个账户的话。
  • authorizationCodeServices:这个属性是用来设置授权码服务的(即 AuthorizationCodeServices 的实例对象),主要用于 “authorization_code” 授权码类型模式。
  • implicitGrantService:这个属性用于设置隐式授权模式,用来管理隐式授权模式的状态。
  • tokenGranter:当你设置了这个东西(即 TokenGranter 接口实现),那么授权将会交由你来完全掌控,并且会忽略掉上面的这几个属性,这个属性一般是用作拓展用途的,即标准的四种授权模式已经满足不了你的需求的时候,才会考虑使用这个。

配置授权端点的URL(Endpoint URLs):

AuthorizationServerEndpointsConfigurer 这个配置对象有一个叫做 pathMapping() 的方法用来配置端点URL链接,它有两个参数:

  • 第一个参数:String 类型的,这个端点URL的默认链接。
  • 第二个参数:String 类型的,你要进行替代的URL链接。

以上的参数都将以 “/“ 字符为开始的字符串,框架的默认URL链接如下列表,可以作为这个 pathMapping() 方法的第一个参数:

  • /oauth/authorize:授权端点。
  • /oauth/token:令牌端点。
  • /oauth/confirm_access:用户确认授权提交端点。
  • /oauth/error:授权服务错误信息端点。
  • /oauth/check_token:用于资源服务访问的令牌解析端点。
  • /oauth/token_key:提供公有密匙的端点,如果你使用JWT令牌的话。

将资源与角色的关系存redis中方便后面鉴权:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class ResourceServiceImpl {

@Autowired
private RedisUtil redisUtil;

@Autowired
private ResourceMapper resourceMapper;

@PostConstruct
public void initData() {
Map<String, String> resourceRolesMap = resourceMapper.selectResourceMap();
if(resourceRolesMap == null){
throw new RuntimeException("资源未配置");
}
redisUtil.hmset("auth:resourceRolesMap", resourceRolesMap);
}

}

oauth2-gateway

接下来我们就可以搭建网关服务了,它将作为Oauth2的资源服务、客户端服务使用,对访问微服务的请求进行统一的校验认证和鉴权操作。

依赖导入:

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<!-- webflux-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>

<!-- 注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>

<!--hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.7</version>
</dependency>
<!--Redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--Redis依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--fastjson依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- actuator-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

yaml配置:

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
server:
port: 8004

spring:
application:
name: oauth2-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
routes:
- id: oauth2-auth-route # 路由ID,没有固定规则但是要求唯一,建议配合服务名
uri: lb://security-test #匹配后提供服务的路由地址
predicates:
- Path=/auth/** #断言,路径相匹配的进行路由
filters:
- StripPrefix=1
- id: oauth2-api-route # 路由ID,没有固定规则但是要求唯一,建议配合服务名
uri: lb://service-provider #匹配后提供服务的路由地址
predicates:
- Path=/api/** #断言,路径相匹配的进行路由
filters:
- StripPrefix=1
discovery:
locator:
enabled: true #开启从注册中心动态创建路由的功能
lower-case-service-id: true #使用小写服务名,默认是大写


redis:
database: 0
host: 127.0.0.1 #Redis服务器地址
lettuce:
pool:
max-active: 20
max-idle: 5
max-wait: -1
min-idle: 0
port: 6379
timeout: 1800000

secure:
ignore:
urls: #配置白名单路径
- "/actuator/**"
- "/auth/oauth/token"

对网关服务进行配置安全配置

由于SpringCloud Gateway是基于webFlux的,跟SpringMVC传统方式是不兼容的:比如你在里面没法使用HttpServletRequest、HttpServletResponse,和HttpSession。

所以需要使用@EnableWebFluxSecurity注解开启

  1. 白名单对象:

    1
    2
    3
    4
    5
    6
    7
    @Data
    @EqualsAndHashCode(callSuper = false)
    @Component
    @ConfigurationProperties(prefix="secure.ignore")
    public class IgnoreUrlsConfig {
    private List<String> urls;
    }
  2. 两个异常处理类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /**
    * @author
    * 自定义返回结果:没有权限访问时
    * @date 2022/12/6 21:10
    */
    @Component
    public class RestfulAccessDeniedHandler implements ServerAccessDeniedHandler {
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
    ServerHttpResponse response = exchange.getResponse();
    response.setStatusCode(HttpStatus.OK);
    response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    response.getHeaders().set("Access-Control-Allow-Origin","*");
    response.getHeaders().set("Cache-Control","no-cache");
    // 返回错误状态类(这里简版省略)
    String body= JSONUtil.toJsonStr(denied.getMessage());
    DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
    return response.writeWith(Mono.just(buffer));
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /**
    * @author wht
    * 自定义返回结果:没有登录或token过期时
    * @date 2022/12/6 21:14
    */
    @Component
    public class RestAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
    ServerHttpResponse response = exchange.getResponse();
    response.setStatusCode(HttpStatus.OK);
    response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    response.getHeaders().set("Access-Control-Allow-Origin","*");
    response.getHeaders().set("Cache-Control","no-cache");
    // 同样返回错误统一状态类
    String body= JSONUtil.toJsonStr(e.getMessage());
    DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
    return response.writeWith(Mono.just(buffer));
    }
    }
  3. 白名单过滤器:

    去除白名单头部的jwt:

    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
    /**
    * @author wht
    * 白名单路径访问时需要移除JWT请求头
    * @date 2022/12/6 21:25
    */
    @Component
    public class IgnoreUrlsRemoveJwtFilter implements WebFilter {
    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    URI uri = request.getURI();
    PathMatcher pathMatcher = new AntPathMatcher();
    //白名单路径移除JWT请求头
    List<String> ignoreUrls = ignoreUrlsConfig.getUrls();
    for (String ignoreUrl : ignoreUrls) {
    if (pathMatcher.match(ignoreUrl, uri.getPath())) {
    request = exchange.getRequest().mutate().header("Authorization", "").build();
    exchange = exchange.mutate().request(request).build();
    return chain.filter(exchange);
    }
    }
    return chain.filter(exchange);
    }
    }
  4. 认证管理器:

    进行jwt的解析,tokenStore保持与认证服务器中的一致并copy过来即可

    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
    @Component
    public class JwtAuthenticationManager implements ReactiveAuthenticationManager {

    private TokenStore tokenStore;

    public JwtAuthenticationManager(TokenStore tokenStore){
    this.tokenStore = tokenStore;
    }


    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
    return Mono.justOrEmpty(authentication)
    .filter(a -> a instanceof BearerTokenAuthenticationToken)
    .cast(BearerTokenAuthenticationToken.class)
    .map(BearerTokenAuthenticationToken::getToken)
    .flatMap((accessToken -> {
    OAuth2AccessToken oAuth2AccessToken = null;
    try {
    oAuth2AccessToken = this.tokenStore.readAccessToken(accessToken);
    }catch (InvalidTokenException e){
    throw new RuntimeException("11111");
    }
    if(oAuth2AccessToken == null){
    return Mono.error(new InvalidTokenException("无效的token!"));
    }else if(oAuth2AccessToken.isExpired()){
    return Mono.error(new InvalidTokenException("token已过期!"));
    }
    OAuth2Authentication oAuth2Authentication = this.tokenStore.readAuthentication(accessToken);
    if(oAuth2Authentication == null){
    return Mono.error(new InvalidTokenException("无效的token!"));
    }else{
    return Mono.just(oAuth2Authentication);
    }
    })).cast(Authentication.class);
    }
    }
  5. 权限管理器:

    作用就是对令牌进行鉴权,如果该令牌无访问资源的权限,则不允通过。

    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
    /**
    * @author wht
    * 鉴权管理器,用于判断是否有资源的访问权限
    * @date 2022/12/6 19:53
    */
    @Component
    public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
    //获取请求uri
    ServerHttpRequest request = authorizationContext.getExchange().getRequest();
    URI uri = request.getURI();
    PathMatcher pathMatcher = new AntPathMatcher();
    // 白名单路径直接放行
    List<String> ignoreUrls = ignoreUrlsConfig.getUrls();
    for (String ignoreUrl : ignoreUrls) {
    if (pathMatcher.match(ignoreUrl, uri.getPath())) {
    return Mono.just(new AuthorizationDecision(true));
    }
    }
    //对应跨域的预检请求直接放行
    if(request.getMethod()== HttpMethod.OPTIONS){
    return Mono.just(new AuthorizationDecision(true));
    }

    // 进行校验权限
    Map<Object, Object> resourceRolesMap = redisUtil.hmget("auth:resourceRolesMap");
    Iterator<Object> iterator = resourceRolesMap.keySet().iterator();
    // 获取该路径所需权限
    List<String> authorities = new ArrayList<>();
    while (iterator.hasNext()) {
    String pattern = (String) iterator.next();
    if (pathMatcher.match(pattern, uri.getPath())) {
    authorities.addAll(Convert.toList(String.class, resourceRolesMap.get(pattern)));
    }
    }
    //认证通过且角色匹配的用户可访问当前路径
    return mono
    // 判断是否认证成功
    .filter(Authentication::isAuthenticated)
    // 获取全部角色权限
    .flatMapIterable(Authentication::getAuthorities)
    .map(GrantedAuthority::getAuthority)
    // 判断是否包含
    .any(authorities::contains)
    .map(AuthorizationDecision::new)
    .defaultIfEmpty(new AuthorizationDecision(false));
    }
    }

    完整配置(注意tokenStore复制之前认证服务器的要保证一致):

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
/**
* @author wht
* 网关安全服务配置
* @date 2022/12/6 19:21
*/
@AllArgsConstructor
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
private final AuthorizationManager authorizationManager;
private final IgnoreUrlsConfig ignoreUrlsConfig;
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;
private final TokenStore tokenStore;


@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
// http.oauth2ResourceServer().jwt().authenticationManager(getAuthenticationManager());
//token管理器
ReactiveAuthenticationManager tokenAuthenticationManager = new JwtAuthenticationManager(tokenStore);
//认证过滤器
AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(tokenAuthenticationManager);
authenticationWebFilter.setServerAuthenticationConverter(new ServerBearerTokenAuthenticationConverter());
//oauth2认证过滤器
http.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);

//自定义处理JWT请求头过期或签名错误的结果
// http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
//对白名单路径,直接移除JWT请求头
http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
//白名单配置
.pathMatchers(ArrayUtil.toArray(ignoreUrlsConfig.getUrls(),String.class)).permitAll()
//鉴权管理器配置
.anyExchange().access(authorizationManager)
.and().exceptionHandling()
//处理未授权
.accessDeniedHandler(restfulAccessDeniedHandler)
//处理未认证
.authenticationEntryPoint(restAuthenticationEntryPoint)
.and().csrf().disable();
return http.build();
}
}

设置全局过滤器:

当鉴权通过后将JWT令牌中的用户信息解析出来,然后存入请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户信息。

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
/**
* @author wht
* 将登录用户的JWT转化成用户信息的全局过滤器
* @date 2022/12/6 21:42
*/
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

private static Logger LOGGER = LoggerFactory.getLogger(AuthGlobalFilter.class);

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StrUtil.isEmpty(token)) {
// jwt为空直接放行
return chain.filter(exchange);
}
//从token中解析用户信息并设置到Header中去
String realToken = StrUtil.subAfter(token, "Bearer ", false);
Claims body = Jwts.parser()
.setSigningKey("wht".getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(realToken)
.getBody();
String userStr = body.toString();
LOGGER.info("AuthGlobalFilter.filter() user:{}",userStr);
ServerHttpRequest request = exchange.getRequest().mutate().header("Authorization", userStr).build();
exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
}

@Override
public int getOrder() {
return 0;
}
}

效果测试

  1. 访问网关获取token(使用密码模式):

    http://localhost:8004/auth/oauth/token

    image-20221207223522234

  2. 带着token去访问服务接口:

    http://localhost:8004/api/user/currentUser