微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

时间:2022-04-27 20:53:44

1.  整体架构

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

在这种结构中,网关就是一个资源服务器,它负责统一授权(鉴权)、路由转发、保护下游微服务。

后端微服务应用完全不用考虑权限问题,也不需要引入spring security依赖,就正常的服务功能开发就行了,不用关注权限。因为鉴权提到网关去做了。

网关负责保护它后面的微服务应用,鉴权就是看“问当前资源所需的权限”和“当前用户拥有的权限”之间是否有交集。

认证服务器负责用户身份的认证校验。

如此一来,认证服务器就专心做用户身份认证,网关就专心做用户访问授权,业务应用就专心做业务开发,分工明确,各司其职

客户端访问服务端接口的过程如下:

1、客户端浏览器(前端)访问后端服务时,网关过滤器发现没有带token,于是返回403未授权

2、客户端重定向到登录页面,用户输入用户名和密码登录后,首先调后端的登录接口,然后获取access_token

3、客户端携带token再次访问服务端接口,网关校验当前用户是否有权限访问该接口

2.  认证服务 (cjs-uaa-server)

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>cjs-uaa-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>cjs-uaa-server</name> <properties>
<java.version>1.8</java.version>
<spring-cloud.version>2020.0.4</spring-cloud.version>
<alibaba-nacos.version>2021.1</alibaba-nacos.version>
</properties> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3.4</version>
</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>
<version>${alibaba-nacos.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.15.2</version>
</dependency>
<!--<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
<version>5.5.3</version>
</dependency>--> <dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<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>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement> <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build> </project>

application.yml

server:
port: 8081
servlet:
context-path: /uaa
spring:
application:
name: cjs-uaa-server
cloud:
nacos:
discovery:
server-addr: 192.168.28.32:8848
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/sso?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
redis:
host: 192.168.28.31
port: 6379
password: 123456
logging:
level:
org:
springframework:
security: debug
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

默认spring security oauth2生成的token就是一串uuid,里面没有带任何信息,通常会把生成的token存到redis中,这样做的话一般问题也不大,就是费点存储空间而已。

这里我们采用JWT来生成token,JWT的优点就是轻量级、无状态、不用存储,缺点是无法主动撤销。

有一点需要注意,JWT本身是无状态的,如果我们把JWT又再存到Redis中这就相当于有状态了,JWT+Redis这对JWT而言就相当于是“伤害不大,侮辱性极强”。唉,没办法,后面要实现退出,我打算这么做了。

JWT的加密方式有对称加密和非对称加密,这里我们采用非对称加密,接下来用java自带的keytool工具生成密钥。

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

生成好的jwt.jks文件我们把它放到resources目录下即可

资源服务器在拿到用户传的access_token以后对它进行解密时需要密钥,为此需要写个接口把公钥暴露出去

获取公钥

@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}

再写个Controller

package com.example.cjsuaaserver.controller;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.util.Map; /**
* @Author ChengJianSheng
* @Date 2021/11/13
*/
@RestController
public class KeyPairController { @Autowired
private KeyPair keyPair; @GetMapping("/rsa/jwks.json")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) this.keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}

WebSecurityConfig.java

package com.example.cjsuaaserver.config;

import com.example.cjsuaaserver.service.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; /**
* @Author ChengJianSheng
* @Date 2021/11/11
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired
private UserDetailsServiceImpl userDetailsService; @Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// .disable() // 禁用默认的表单登录
.and()
.authorizeRequests()
.antMatchers("/rsa/jwks.json", "/oauth/login").permitAll()
.anyRequest().authenticated()
.and()
// .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// .and()
.csrf().disable().cors();
} @Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
} @Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
} @Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
} }

UserDetailsServiceImpl.java

package com.example.cjsuaaserver.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.cjsuaaserver.domain.UserDetailsDTO;
import com.example.cjsuaaserver.entity.SysPermission;
import com.example.cjsuaaserver.entity.SysRolePermission;
import com.example.cjsuaaserver.entity.SysUser;
import com.example.cjsuaaserver.entity.SysUserRole;
import com.example.cjsuaaserver.service.ISysPermissionService;
import com.example.cjsuaaserver.service.ISysRolePermissionService;
import com.example.cjsuaaserver.service.ISysUserRoleService;
import com.example.cjsuaaserver.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; /**
* @Author ChengJianSheng
* @Date 2021/11/11
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService { @Autowired
private ISysUserService sysUserService;
@Autowired
private ISysUserRoleService sysUserRoleService;
@Autowired
private ISysRolePermissionService sysRolePermissionService;
@Autowired
private ISysPermissionService sysPermissionService; @Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
QueryWrapper<SysUser> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", s);
List<SysUser> sysUserList = sysUserService.list();
if (null == sysUserList) {
throw new UsernameNotFoundException("用户不存在");
}
SysUser sysUser = sysUserList.get(0);
QueryWrapper<SysUserRole> queryWrapper1 = new QueryWrapper<>();
queryWrapper1.eq("user_id", sysUser.getId());
List<SysUserRole> sysUserRoleList = sysUserRoleService.list(queryWrapper1);
List<Integer> roleIds = sysUserRoleList.stream().map(SysUserRole::getRoleId).collect(Collectors.toList()); QueryWrapper<SysRolePermission> queryWrapper3 = new QueryWrapper<>();
queryWrapper3.in("role_id", roleIds);
List<SysRolePermission> sysRolePermissionList = sysRolePermissionService.list(queryWrapper3);
List<Integer> permissionIds = sysRolePermissionList.stream().map(SysRolePermission::getPermissionId).collect(Collectors.toList()); List<SysPermission> sysPermissionList = sysPermissionService.listByIds(permissionIds); Set<SimpleGrantedAuthority> authorities = sysPermissionList.stream().map(SysPermission::getUrl).map(SimpleGrantedAuthority::new).collect(Collectors.toSet()); return new UserDetailsDTO(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), true, authorities);
}
}

接下来,认证服务配置尤为重要,这里面配置了认证客户端和token增强,AuthorizationServerConfig.java

package com.example.cjsuaaserver.config;

import com.example.cjsuaaserver.domain.UserDetailsDTO;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
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.OAuth2Authentication;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory; import javax.annotation.Resource;
import javax.sql.DataSource;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map; /**
* @Author ChengJianSheng
* @Date 2021/11/11
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Resource
private DataSource dataSource; @Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
} @Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(new JdbcClientDetailsService(dataSource));
} @Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
List<TokenEnhancer> tokenEnhancerList = new ArrayList<>();
tokenEnhancerList.add(jwtTokenEnhancer());
tokenEnhancerList.add(jwtAccessTokenConverter());
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(tokenEnhancerList); endpoints.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain);
} public TokenEnhancer jwtTokenEnhancer() {
return new TokenEnhancer() {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
UserDetailsDTO userDetailsDTO = (UserDetailsDTO) authentication.getPrincipal();
Map<String, Object> additionalInformation = new HashMap<>();
additionalInformation.put("userId", userDetailsDTO.getUserId());
additionalInformation.put("username", userDetailsDTO.getUsername());
((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(additionalInformation);
return accessToken;
}
};
} public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setKeyPair(keyPair());
return jwtAccessTokenConverter;
} @Bean
public KeyPair keyPair() {
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}
}

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

还有最后一个问题,既然是前后端分离,那么就需要我们自己实现登录处理逻辑以及退出

登录逻辑比较简单,利用自带的AuthenticationManager进行认证即可。由于是授权码模式,所以登录成功以后,需要调用/oauth/token获取access_token,而默认该接口返回的数据结构跟我们自己统一定义的返回结构不一样,为此最简单的方式就是写一个跟它一模一样的Controller这样就覆盖了默认的那个TokenEndpoint里面的/oauth/token

为了实现退出,我们在获取到token以后,将它存到redis中,退出时从redis中将它删除

LoginController.java

package com.example.cjsuaaserver.controller;

import com.example.cjsuaaserver.domain.LoginDTO;
import com.example.cjsuaaserver.domain.RespResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.*; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.security.Principal;
import java.util.Map;
import java.util.concurrent.TimeUnit; /**
* @Author ChengJianSheng
* @Date 2021/11/17
*/
@RestController
@RequestMapping("/oauth")
public class LoginController { @Autowired
private TokenEndpoint tokenEndpoint;
@Resource
private AuthenticationManager authenticationManager;
@Autowired
private StringRedisTemplate stringRedisTemplate; /**
* 获取Token
* 这里取巧直接定义了一个跟TokenEndpoint里面一样的请求路径,这样的话自动的就被覆盖了。请求通过我们自定义的这个方法,就可以对请求结构进行封装,按照我们想要的格式返回了。
* 多次实验我发现,一定有过滤器会对/oauth/token进行拦截处理,不然第一个参数principal根本就不会有值,这里的principal代表的是oauth2客户端
* 如果自己随便定义一个不叫/oauth/token的话,请求的时候又不知道怎么传参 o(╥﹏╥)o
* 网上看到还有一种方式是自己定义一个aop去拦截这个请求,并修改返回数据格式
*/
@PostMapping("/token")
public RespResult getAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException { OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody(); // 放入Redis中,为了实现退出登录
String jti = (String) oAuth2AccessToken.getAdditionalInformation().get("jti");
int expiresIn = oAuth2AccessToken.getExpiresIn();
String key = "TOKEN:" + jti;
String value = oAuth2AccessToken.getValue();
stringRedisTemplate.opsForValue().set(key, value, expiresIn, TimeUnit.SECONDS); return new RespResult(200, "success", true, oAuth2AccessToken);
} /**
* 登录
* @param dto
* @return
*/
@PostMapping("/login")
public RespResult login(@RequestBody LoginDTO dto) {
// 校验验证码 // 登录
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword());
Authentication authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication); Object principal = authentication.getPrincipal();
System.out.println(principal);
return new RespResult(200, "success", true, principal);
} /**
* 退出(清除缓存)
*/
@GetMapping("/logout")
public RespResult logout(HttpServletRequest request) {
String userStr = request.getHeader("user"); // 解析出jti
String jti = "xxx";
// 清除缓存 stringRedisTemplate.delete("TOKEN:" + jti);
return null;
}
}

3.  业务应用服务 (cjs-order-server)

pom.xml

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>${alibaba-nacos.version}</version>
</dependency>

application.yml

server:
port: 8082
servlet:
context-path: /order
spring:
application:
name: cjs-order-server
cloud:
nacos:
discovery:
server-addr: 192.168.28.32:8848

OrderController.java

package com.example.cjsorderserver.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; /**
* @Author ChengJianSheng
* @Date 2021/11/16
*/
@RestController
@RequestMapping("/order")
public class OrderController { @GetMapping("/pageList")
public String pageList() {
return "good";
}
}

4. 网关 (cjs-gateway-server)

网关服务特别重要

pom.xml 

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>cjs-gateway-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>cjs-gateway-server</name> <properties>
<java.version>1.8</java.version>
<spring-cloud.version>2020.0.4</spring-cloud.version>
<alibaba-nacos.version>2021.1</alibaba-nacos.version>
<spring-security-oauth2.version>5.6.0</spring-security-oauth2.version>
</properties> <dependencies>
<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.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>${alibaba-nacos.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
<version>${spring-security-oauth2.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
<version>${spring-security-oauth2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.78</version>
</dependency> <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>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement> <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build> </project>

application.yml

server:
port: 8080
spring:
application:
name: cjs-gateway-server
cloud:
nacos:
discovery:
server-addr: 192.168.28.32:8848
gateway:
routes:
- id: oauth2-auth
uri: lb://cjs-uaa-server
predicates:
- Path=/uaa/**
- id: order-server
uri: lb://cjs-order-server
predicates:
- Path=/order/**
discovery:
locator:
enabled: true
lower-case-service-id: true
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8081/uaa/rsa/jwks.json
redis:
host: 192.168.28.31
port: 6379
password: 123456
secure:
ignore:
urls:
- /order/hello/sayHello
- /uaa/**
logging:
level:
org.springframework.cloud.gateway: debug

作为资源服务器这样一个角色,自然少不了资源服务器配置

ResourceServerConfig.java

package com.example.gateway.config;

import com.example.gateway.handler.CustomAuthorizationManager;
import com.example.gateway.handler.CustomServerAccessDeniedHandler;
import com.example.gateway.handler.CustomServerAuthenticationEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono; /**
* @Author ChengJianSheng
* @Date 2021/11/14
*/
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig { @Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;
@Autowired
private CustomAuthorizationManager customAuthorizationManager;
@Autowired
private CustomServerAccessDeniedHandler customServerAccessDeniedHandler;
@Autowired
private CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint; @Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());
http.authorizeExchange()
.pathMatchers(ignoreUrlsConfig.getUrls()).permitAll()
.anyExchange().access(customAuthorizationManager)
.and()
.exceptionHandling()
.accessDeniedHandler(customServerAccessDeniedHandler)
.authenticationEntryPoint(customServerAuthenticationEntryPoint)
.and()
.csrf().disable(); return http.build();
} public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}

IgnoreUrlsConfig.java 是用来配置不需要授权的url的

package com.example.gateway.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; /**
* @Author ChengJianSheng
* @Date 2021/11/15
*/
@Data
@Component
@ConfigurationProperties(prefix = "secure.ignore")
public class IgnoreUrlsConfig {
private String[] urls;
}

利用ReactiveAuthorizationManager实现授权逻辑

CustomAuthorizationManager.java

package com.example.gateway.handler;

import com.example.gateway.constant.AuthConstants;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import java.util.ArrayList;
import java.util.List; /**
* @Author ChengJianSheng
* @Date 2021/11/15
*/
@Component
public class CustomAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> { @Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext context) {
ServerHttpRequest request = context.getExchange().getRequest();
String path = request.getURI().getPath(); String token = request.getHeaders().getFirst(AuthConstants.JWT_TOKEN_HEADER);
if (StringUtils.isBlank(token)) {
return Mono.just(new AuthorizationDecision(false));
} List<String> authorities = new ArrayList<>();
authorities.add(AuthConstants.ROLE_PREFIX + path); return authentication.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
// .any(authorities::contains)
.any(roleId->{
System.out.println(path);
System.out.println(roleId);
System.out.println(authorities);
return authorities.contains(roleId);
})
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

这里的资源就是url,就是请求路径,资源所需的权限可以从数据库或者缓存Redis中查,也可以提前将所有资源与权限的对应关系存到Redis中,这里直接去取

CustomServerAccessDeniedHandler.java

package com.example.gateway.handler;

import com.alibaba.fastjson.JSON;
import com.example.gateway.domain.Result;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono; import java.nio.charset.Charset; /**
* @Author ChengJianSheng
* @Date 2021/11/15
*/
@Component
public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String body = JSON.toJSONString(new Result("拒绝访问"));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
return exchange.getResponse().writeWith(Mono.just(buffer));
}
}

CustomServerAuthenticationEntryPoint.java

package com.example.gateway.handler;

import com.alibaba.fastjson.JSON;
import com.example.gateway.domain.Result;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono; import java.nio.charset.Charset; /**
* @Author ChengJianSheng
* @Date 2021/11/15
*/
@Component
public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String body = JSON.toJSONString(new Result("未认证"));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
return response.writeWith(Mono.just(buffer));
}
}

最后,为了避免业务应用自己还要去解析token,网关在将授权通过的请求转发给下游业务应用时,应该提前将token解析好,并放到请求头中,这样业务应用直接从请求头中获取用户信息

CustomGlobalFilter.java

package com.example.gateway.filter;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.example.gateway.constant.AuthConstants;
import com.example.gateway.domain.Result;
import com.nimbusds.jose.JWSObject;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono; import java.nio.charset.Charset;
import java.text.ParseException; /**
* @Author ChengJianSheng
* @Date 2021/11/17
*/
@Component
public class CustomGlobalFilter implements GlobalFilter, Ordered { @Autowired
private StringRedisTemplate stringRedisTemplate; @Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst(AuthConstants.JWT_TOKEN_HEADER);
if (StringUtils.isBlank(token)) {
return chain.filter(exchange);
} String realToken = token.replace(AuthConstants.JWT_TOKEN_PREFIX, "");
try {
JWSObject jwsObject = JWSObject.parse(realToken);
String payload = jwsObject.getPayload().toString();
JSONObject jsonObject = JSON.parseObject(payload);
String jti = jsonObject.getString("jti"); boolean flag = stringRedisTemplate.hasKey(AuthConstants.TOKEN_WHITELIST_PREFIX + jti);
if (!flag) {
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 = JSON.toJSONString(new Result("无效的Token"));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
return response.writeWith(Mono.just(buffer));
} ServerHttpRequest request = exchange.getRequest().mutate().header("user", payload).build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
e.printStackTrace();
} return chain.filter(exchange);
} @Override
public int getOrder() {
return 0;
}
}

5.  演示

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

这里为了演示没有禁用默认的表单登录,正式开发的时候最后禁用默认登录,改用自定义登录页面

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

登录

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

授权

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

这里由于重定向到百度,在postman中不好看返回的code,我们用Wireshark看

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

通过Wireshark可以看到,确实重定向了

然后获取token

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

检查token

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

携带access_token访问业务接口,比如 http://localhost:8082/order/order/pageList 可以看到请求header中确实带了已经解析好的用户信息 ,那是我们在前面CustomGlobalFilter.java中放的

微服务下前后端分离的统一认证授权服务,基于Spring Security OAuth2 + Spring Cloud Gateway实现单点登录

最后,补充一点:业务应用中利用利用过滤器将header中的用户信息放到ThreadLocal变量中,一遍后续方法中可以直接从上下文中获取

UserInfo.java

package com.cjs.component.user.domain;

import lombok.Data;

import java.io.Serializable;

/**
* @Author ChengJianSheng
* @Date 2021/12/1
*/
@Data
public class UserInfo implements Serializable { private Long userId; private String username; private String mobile;
}

UserInfoContext.java

package com.tgf.component.user.service;

import com.tgf.component.user.domain.UserInfo;

/**
* @Author ChengJianSheng
* @Date 2021/12/1
*/
public class UserInfoContext { public static final String HEADER_USER_INFO = "X-USERINFO"; private static ThreadLocal<UserInfo> threadLocal = new ThreadLocal<>(); public static UserInfo get() {
return threadLocal.get();
} public static void set(UserInfo userInfo) {
threadLocal.set(userInfo);
} }

UserInfoFilter.java

package com.cjs.component.user.filter;

import com.alibaba.fastjson.JSON;
import com.tgf.component.user.domain.UserInfo;
import com.tgf.component.user.service.UserInfoContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException; /**
* @Author ChengJianSheng
* @Date 2021/12/1
*/
@Slf4j
@Order(1)
@Component
public class UserInfoFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String userInfoJson = ((HttpServletRequest) request).getHeader(UserInfoContext.HEADER_USER_INFO);
if (StringUtils.isNotBlank(userInfoJson)) {
UserInfo userInfo = JSON.parseObject(userInfoJson, UserInfo.class);
UserInfoContext.set(userInfo);
}
chain.doFilter(request, response);
}
}

参考文档

https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/html5/#boot-features-security-oauth2-single-sign-on

https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories

https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.security.oauth2.server

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#webflux-oauth2resourceserver-jwt-authorization