前篇:Spring Cloud 微服务安全 | (一) API 安全
本篇源代码:https://github.com/hedon954/spring-security-oauth2.0
其他参考代码:OAuth2.0 内存版;OAuth2.0 数据库版
这里只需要搭建一个空的父工程就可以了,后面在这个工程目录下创建新的 Module 即可。
在父工程目录下新建一个 Module,Maven 的基础项目即可。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-dependenciesartifactId>
<version>2.3.4.RELEASEversion>
<type>pomtype>
<scope>importscope>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-dependenciesartifactId>
<version>Hoxton.SR3version>
<type>pomtype>
<scope>importscope>
dependency>
dependencies>
dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
dependencies>
@SpringBootApplication
public class PriceServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PriceServiceApplication.class,args);
}
}
server:
port: 9080
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Price {
private Integer orderId;
private Double price;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class PriceDto {
private Integer orderId;
private Double price;
}
@RestController
@RequestMapping("/price")
public class PriceController {
//传一个 Order Id,传回相应的价格
@GetMapping("/{id}")
public PriceDto getByOrderId(@PathVariable("id") Integer id){
PriceDto priceDto = new PriceDto();
priceDto.setOrderId(id);
priceDto.setPrice(100.11);
return priceDto;
}
}
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-dependenciesartifactId>
<version>2.3.4.RELEASEversion>
<type>pomtype>
<scope>importscope>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-dependenciesartifactId>
<version>Hoxton.SR3version>
<type>pomtype>
<scope>importscope>
dependency>
dependencies>
dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
dependencies>
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class,args);
}
}
server:
port: 9090
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Order {
private Integer id;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class OrderDto {
private Integer id;
}
将价格服务的 Price 和 PriceDto 分贝拷贝到订单服务的 bean 包和 dto 包下面
创建控制器 OrderController
这里先不连接数据库。
@RestController
@RequestMapping("/order")
public class OrderController {
private RestTemplate restTemplate = new RestTemplate();
@PostMapping("/create")
public OrderDto create(@RequestBody OrderDto orderDto){
//去访问价格服务,获取订单的价格
ResponseEntity<PriceDto> entity = restTemplate.getForEntity("http://localhost:9080/price/" + orderDto.getId(), PriceDto.class);
PriceDto priceDto = entity.getBody();
System.out.println(priceDto);
return orderDto;
}
}
如上。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-dependenciesartifactId>
<version>2.3.4.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-dependenciesartifactId>
<version>Hoxton.SR3version>
dependency>
dependencies>
dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
<version>2.3.4.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
<version>2.3.1.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
<version>2.2.1.RELEASEversion>
dependency>
dependencies>
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class,args);
}
}
server:
port: 9070
基本的框架搭建就先到这里,我们这里还没有引入 Zuul 网关。这里我们先打通整个 OAuth2.0 的认证和授权,后面再来引入网关,进一步实现网关安全。
@Configuration
@EnableAuthorizationServer //认证服务器
public class OAuth2AuthServerConfig extends AuthorizationServerConfigurerAdapter {
//配置客户端应用的相关信息
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
super.configure(clients);
}
//配置令牌管理器和令牌的存储方式
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
super.configure(endpoints);
}
//配置谁能来验 token (有一些请求连验 token 的资格都没有)
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
super.configure(security);
}
}
这里我们需要创建一个配置类(@Configuration
)去继承 AuthorizationServerConfigurerAdapter
作为认证服务器(@EnableAuthorizationServer
)。继承 AuthorizationServerConfigurerAdapter 是为了要实现它的 3 个方法,3 个方法的名称都为 configure
,但是它们的传参是不同的。
configure(ClientDetailsServiceConfigurer clients):配置客户端应用的相关信息
所谓客户端,不是指用户,而是指客户端应用,比如我们的 postman 或者一个 app。
configure(AuthorizationServerEndpointsConfigurer endpoints):配置令牌管理器
这里我们到时候会配置一个 authenticationManager
来管理令牌(token)的访问端点(哪些用户可以来访问)和令牌服务(token services),还可以管理令牌的存储方式(内存方式、Jdbc 方式、自定义 ClientDetailsService 方式)。
configure(AuthorizationServerSecurityConfigurer security):配置令牌端点的安全约束
这里我们会配置哪些请求可以来验 token,哪些请求需要来验 token,哪些请求不可以来验 token(直接 ko 掉),哪些请求不需要来验 token(直接放过)。
这里我们先配置到内存里面,后面会改进为数据库版本。
@Autowired
private PasswordEncoder passwordEncoder;
//配置客户端应用的相关信息
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("orderService") //客户端ID
.secret(passwordEncoder.encode("123456")) //客户端密码
.scopes("read","write") //用户对该客户端可以申请哪些权限
.accessTokenValiditySeconds(3600) //令牌的有效期(单位为秒)
.resourceIds("order-service") //资源ID
.authorizedGrantTypes("password") //用户要访问该客户端的时候采取的授权类型
.and()
.withClient("priceService")
.secret(passwordEncoder.encode("123456"))
.scopes("read")
.accessTokenValiditySeconds(3600)
.resourceIds("price-service")
.authorizedGrantTypes("password");
}
withClient & secret:客户端ID和客户端密码
这里就类似于一个 username 和 password,不过它不是指用户的信息,而且一个作为客户端的应用的信息,后面我们使用 postman(也就是客户端应用)要来认证服务器获取 token 的时候,就需要携带自己的客户端 ID 和 secret 过来,让认证服务器判断它是否是认证服务器的一个客户端,然后再决定是否要发放 token。
scopes:权限
这里 scopes 是规定一个用户可以拿到的关于该资源服务器的所有的权限,这里 orderService 规定了可以有 write 和 read 权限,所以这里你可以申请 write 和 read 权限。但是你这里如果想要申请 fly 权限,那就会报错啦!
accessTokenValiditySeconds:令牌的有效期,单位是秒
resourceIds:资源ID
这里是配置当前这个客户端它允许访问哪些资源服务器。我们资源服务器在做配置的时候是需要配置自己的资源服务器 ID 的,它需要与认证服务器配置的某个客户端中的资源ID一一对应。
authorizedGrantTypes:客户端的授权类型,OAuth2.0 总共有 4 种授权类型,后面再做详细介绍:
注意:这里的 passwordEncoder 我们来没配置。
@Autowired
private AuthenticationManager authenticationManager;
//配置令牌管理器 => ① 管理访问端点;② 管理令牌服务(哪种类型的令牌、令牌中的用户信息、密码加密模式)
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//这部分由 authenticationManager 来管理
endpoints.authenticationManager(authenticationManager);
}
这里的 authenticationManager 我们后面再配,这里写上。
//配置令牌端点的安全约束 => 配置谁能来验 token (有一些请求连验 token 的资格都没有)
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//这里设置必须带身份信息来验证token才进行验证
security.checkTokenAccess("isAuthenticated()");
}
这里还可以设置(本案例没有设置):
security
.tokenKeyAccess("permitAll()") //公有密钥的端点 /oauth/token_key 是公开的
.checkTokenAccess("permitAll()") //资源服务可以远程校验令牌的合法性 /oauth/check_token 是公开的
.allowFormAuthenticationForClients(); //表单认证(申请令牌)
@Configuration
@EnableWebSecurity //支持 WebSecurity
public class OAuth2WebSecurityConfig extends WebSecurityConfigurerAdapter {
我们前面还留着 authenticationManager
和 passwordEncoer
没有配置,我们可以在这个类进行配置。
在配置之前,我们可以先来看看 AuthenticationManager
这个接口有什么东西:
它里面只有一个 authenticate()
方法,也就是负责认证的方法,返回值类型是 Authentication
,我们来看看这个类:
现在我们来配置 authenticationManage
:
@Autowired
private UserDetailsService userDetailsService;
//采用 BCrypt 加密 (注:如果这里注入 passwordEncoder 会报错,可以把这个注入放在主启动类里面,然后这里用 @Autowired 注入)
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//配置如何构建 AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService) //用户详细信息(用户名、权限等)
.passwordEncoder(passwordEncoder()); //密码加密器
}
在 authenticationManager
中,这里我们就先配置两个东西 userDetailsService
和 passwordEncoder
。 userDetailsService
我们待会再来配置,这里先写上。
前面只是配置了如何构建 authenticationManager,但是 authenticationManager 来没有注入到我们的 Spring IoC 容器当中,想要将其注入,这里需要重写 WebSecurityConfigurerAdapter 中的 authenticationManagerBean()
方法然后加上 @Bean
注解,从名字上看这个方法的作用就一目了然了。
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
这样就会将 authenticationManager 对象注入到 Spring IoC 容器当中了。
现在我们来实现前面还没实现的 userDetailsService
。在这之前,我们先来看看 userDetailsService
有什么东西:
我们可以看到它只有一个方法 loadUserByUsername()
,它的返回值是一个 UserDetails
类型的,我们来看看 UserDetails
类有什么属性:
所以我们只需编写一个类来实现 userDetailsService
然后实现它的 loadUserByUsername()
方法就可以了,真正的场景肯定是需要访问数据库然后进行比对判断的,这里我们先简单配置,后面再转为数据库版本:
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//这里表示 username 任意,密码只要是 123456 就可以了。
return User.withUsername(username)
.password(passwordEncoder.encode("123456"))
.authorities("ROLE_ADMIN") //我们这里设置一个全新叫"ROLE_ADMIN"
.build();
}
}
到此为止,认证服务器的简单配置我们就完成了,来测试一下。
我们先来进行正常情况的测试。OAuth2.0 获取 token 的路径是 /oauth/token
,在访问的时候需要携带两个信息:
携带上面两个信息后我们就可以获取 token 了:
我们改一下客户端ID,乱写一个:
自此,认证服务器的测试就完成啦~下面我们来搭建资源服务器 Order-Service。
先给 Order-Service 的 pom 文件导入 OAuth2.0 相关的依赖坐标:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
<version>2.2.1.RELEASEversion>
dependency>
@Configuration
@EnableResourceServer //资源服务器
public class OAuth2ResourcesServerConfig extends ResourceServerConfigurerAdapter {
//配置资源服务器 ID
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
super.configure(resources);
}
//控制权限 => 默认所有服务都需要携带token
@Override
public void configure(HttpSecurity http) throws Exception {
super.configure(http);
}
}
在资源服务器配置类中,我们需要继承ResourceServerConfigurerAdapter
类,然后实现它的两个 configure()
方法。分别是:
configure(ResourceServerSecurityConfigurer resources):配置资源服务器 ID
这里配置的资源服务器 ID 就是我们之前在认证服务器中注册了的资源服务器的 ID 中的某一个。
configure(HttpSecurity http):控制请求的访问权限
这里我们可以配置哪些请求不需要进行认证,哪些请求需要进行认证,哪些请求需要什么样的权限才可以访问,还可以进行跨域安全相关的配置,还可以配置支持表单登录等配置。
//配置资源服务器 ID
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("order-service");
}
//控制权限 => 默认所有服务都需要携带token
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/haha/haha").permitAll() // "/haha/haha" 不需要验令牌,直接放行
.anyRequest().authenticated(); //其他请求都必须验证令牌
}
这里我们随便写一个 /haha/haha 接口:
@RestController
@RequestMapping("/haha")
public class HahaController {
@GetMapping("/haha")
public String haha(){
return "哈哈!我是 /haha/haha 接口,我不需要认证就可以访问啦!";
}
}
@Configuration
@EnableWebSecurity
public class OAuth2WebSecurityConfig extends WebSecurityConfigurerAdapter {
我们前面配置了哪些请求需要验证令牌,那么问题就来了,去哪里验证令牌呢?这就是这个配置类要解决的问题。
在这个配置类中,我们需要配置认证服务器,告诉资源服务器应该去哪里验证令牌(这里其实就是 OAuth2.0 四种认证授权类型中的 —— 客户端类型)。
//配置令牌验证服务
@Bean
public ResourceServerTokenServices tokenServices(){
RemoteTokenServices remote = new RemoteTokenServices();
remote.setClientId("orderService"); //客户端ID
remote.setClientSecret("123456"); //客户端secret
remote.setCheckTokenEndpointUrl("http://localhost:9070/oauth/check_token");//配置去哪里验证 token
return remote;
}
这部分的配置只是配置了客户端的相关信息,我们还需要配置用户相关的信息,由前面的配置我们可以知道,要来验证用户的相关信息的话那就需要一个 AuthenticationManager
了,但是这个时候我们就不能使用默认的 AuthenticationManager
了,我们需要自己写一个,然后设置上前面的 tokenSevices
,让它去找认证服务器认证。
//利用上面的服务去验证 token
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
OAuth2AuthenticationManager manager = new OAuth2AuthenticationManager();
manager.setTokenServices(tokenServices());
return manager;
}
现在我们还需要获取用户的相关信息,那就得想到 UserDetailsService
了,所以这里我们还是需要实现这个接口:
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
}
这里我们就需要重写 loadUserByUsername()
方法,它的返回值类型是 UserDetails
,我们现在来写一个User 类来实现 UserDetails
,然后实现里面的方法,这里我们数据都先写死,都不需要访问数据库:
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class User implements UserDetails {
//我们自己加3个属性
private Integer id;
private String username;
private String password;
//拥有哪些权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_ADMIN");
}
//密码
@Override
public String getPassword() {
return password;
}
//用户名
@Override
public String getUsername() {
return username;
}
//是否过期 -> 没有过期
@Override
public boolean isAccountNonExpired() {
return true;
}
//是否被锁 -> 没有被锁
@Override
public boolean isAccountNonLocked() {
return true;
}
//证书是否过期 -> 没有过期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
//用户是否可用 -> 可用
@Override
public boolean isEnabled() {
return true;
}
}
写完 User 类后我们回到 loadUserByUsername()
这个方法上,这里为了简单,我们直接返回一个死的 User 对象就可以了:
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = new User();
user.setId(1);
user.setUsername(username);
return user;
}
}
这样用户信息相关类 UserDetailsServiceImpl
就写好了。我们回到 OrderController 类
。
现在我们希望用户访问的接口也能知道令牌里面信息到底是哪个用户相关的信息,比如:
① 只想获取用户名 username
@PostMapping("/create")
public OrderDto create(@RequestBody OrderDto orderDto, @AuthenticationPrincipal String username){
System.out.println("user is " + username);
……………………
return orderDto;
}
那么这里就不需要对 OAuth2WebSecurityConfig类
再做任何配置了,默认就可以获得了。
② 希望获取整个用户 User 的所有信息
@PostMapping("/create")
public OrderDto create(@RequestBody OrderDto orderDto, @AuthenticationPrincipal User user){
System.out.println("user is " + user);
…………………………
return orderDto;
}
那么这里就可以配置一个令牌转换器了,它可以将令牌中的用户信息解析出来并进行封装。
③ 获取用户 User 的某一个属性
@PostMapping("/create")
public OrderDto create(@RequestBody OrderDto orderDto, @AuthenticationPrincipal(expression = "#this.id") Integer id){
System.out.println("userId is " + id);
……………………
return orderDto;
}
这个的话就需要在 ② 的基础上用 expression 表达式
来获取了,比如这里只要获取 id。
来到 OAuth2WebSecurityConfig类
加上:
@Bean
public AccessTokenConverter accessTokenConverter(){
DefaultAccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();
tokenConverter.setUserTokenConverter();
}
写到这里,它需要设置一个 UserTokenConverter
,所以我们需要主动在这个方法里面实例化一个 UserTokenConverter 实现类,为什么需要它呢?因为我们想要拿到用户信息,但是我们到目前为止还没有看到 UserDetailsService
所以这个 UserTokenConverter
就是用来放 UserDetailsService
的:
@Autowired
@Qualifier("userDetailsServiceImpl")
private UserDetailsService userDetailsService;
@Bean
public AccessTokenConverter accessTokenConverter(){
DefaultAccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();
DefaultUserAuthenticationConverter authenticationConverter = new DefaultUserAuthenticationConverter();
authenticationConverter.setUserDetailsService(userDetailsService);
tokenConverter.setUserTokenConverter(authenticationConverter);
return tokenConverter;
}
配置为后,我们将其设置到 AuthenticationManager 中:
//配置令牌验证服务
@Bean
public ResourceServerTokenServices tokenServices(){
RemoteTokenServices remote = new RemoteTokenServices();
remote.setClientId("orderService"); //客户端ID
remote.setClientSecret("123456"); //客户端secret
remote.setCheckTokenEndpointUrl("http://localhost:9070/oauth/check_token");//配置去哪里验证 token
remote.setAccessTokenConverter(accessTokenConverter()); //令牌解析器
return remote;
}
这样就可以在接口获取到用户的所有信息了:
@PostMapping("/create")
public OrderDto create(@RequestBody OrderDto orderDto, @AuthenticationPrincipal User user){
现在我们来测试资源服务器,让它先去认证服务器拿到 token,然后用户携带这个 token 来发送请求,看看效果。
获取token
拿 token 去创建订单
先传递 Order 的相关数据:
然后携带 token 进行访问:
没有问题,再来看看后台是否有打印用户的相关信息:
这次不携带任何信息来访问 /haha/haha 接口,我们前面设置了它是不需要认证的:
没有问题。
我们前面只是实现了 token 的获取和认证,还没有实现对不同请求的不同权限的控制。我们现在来改进一下。
@GetMapping("/{id}")
public OrderDto getById(@PathVariable("id")Integer id){
OrderDto orderDto = new OrderDto();
orderDto.setId(id);
return orderDto;
}
//控制权限 => 默认所有服务都需要携带token
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/haha/haha").permitAll() // "/haha/haha" 不需要认证,直接放行
.antMatchers(HttpMethod.POST).access("#oauth2.hasScope('write')") //POST 请求必须有 write 权限
.antMatchers(HttpMethod.GET).access("#oauth2.hasScope('read')") //GET 请求必须有 read 权限
.anyRequest().authenticated(); //其他请求都必须经过认证,但具体权限不做约束
}
我们先获取一个只有 read 权限的 token
访问 GET 类型的接口:
没有问题。
访问 POST 类型的接口:
403:已认证,但是没权限。
我们现在来获取一个拥有 read 和 write 的用户然后在来访问 POST 请求:
没有问题。
这样一个最基本的 ACL 权限控制就完成了。
我们之前客户端应用使用的是 inMemory()
将其放到内存里,这样只要服务器一重启就会将 token 清空。即使是不清空,两台不同机器之前验证内存里面的 token 也是不通过的。所以我们现在要做两件事:
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>8.0.21version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-jdbcartifactId>
<version>2.3.4.RELEASEversion>
dependency>
这里需要建立 OAuth2.0 规定的一系列数据库表,关于这些表的每一个字段的具体作用,其实从名字就可以很容易推断出来了。当然读者可以自行查阅其他相关信息,这里就不进行赘述了。
create table oauth_client_details (
client_id VARCHAR(256) PRIMARY KEY,
resource_ids VARCHAR(256),
client_secret VARCHAR(256),
scope VARCHAR(256),
authorized_grant_types VARCHAR(256),
web_server_redirect_uri VARCHAR(256),
authorities VARCHAR(256),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(256)
);
create table oauth_client_token (
token_id VARCHAR(256),
token BLOB,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256)
);
create table oauth_access_token (
token_id VARCHAR(256),
token BLOB,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256),
authentication BLOB,
refresh_token VARCHAR(256)
);
create table oauth_refresh_token (
token_id VARCHAR(256),
token BLOB,
authentication BLOB
);
create table oauth_code (
code VARCHAR(256), authentication BLOB
);
create table oauth_approvals (
userId VARCHAR(256),
clientId VARCHAR(256),
scope VARCHAR(256),
status VARCHAR(10),
expiresAt DATETIME,
lastModifiedAt DATETIME
);
这里 client_secret
在存储的时候我们需要存密文,我们可以建立一个测试方法来对 123456 进行加密,然后再将密文存到数据库中:
然后修改 认证服务器配置类 OAuth2AuthServerConfig
中存储客户端应用的方式,我们这里采用默认 jdbc 存储。这样在客户端获取 token 的时候,OAuth2.0 就会将 token 存到数据库中:
//注入默认的数据源
@Autowired
private DataSource dataSource;
//配置客户端应用的相关信息
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
客户端应用的持久化我们已经做好了,现在我们要将 token 持久化,这里我们需要配置一个叫 tokenStore
的对象,从名字上看就可以知道它就是负责来存储 token 的了,现在来到 认证服务器配置类 OAuth2AuthServerConfig
:
//注入默认的数据源
@Autowired
private DataSource dataSource;
//token 存储器
@Bean
public TokenStore tokenStore(){
return new JdbcTokenStore(dataSource);
}
配置完这个后,我们来修改 OAuth2AuthServerConfig
中的 令牌管理器配置方法configure(AuthorizationServerEndpointsConfigurer endpoints)
:
//配置令牌管理器
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.tokenStore(tokenStore()) //配置令牌存储模式
.authenticationManager(authenticationManager);
}
这样就可以将 token 持久化了,当客户端获取 token 的时候,就将 token 的相关信息存储到数据库中。
来到 auth-service
的 application.yml
:
server:
port: 9070
spring:
application:
name: auth-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/imooc-security?useSSL=false&useUnicode=true&characterEncoding=UTF8&serverTimezone=Asia/Shanghai
username: root
password: root
获取 token 没有问题,来看看数据库有没有存储 token:
没有问题~ 下面我们来引入网关,完成我们整个微服务的安全认证。
我们先来看看就目前为止我们的案例中还存在着什么样的问题:
加入网关后:
解决了:
然后导入 pom 坐标依赖,这里先不引入安全相关的依赖,我们先实现路由转发功能。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-dependenciesartifactId>
<version>2.3.4.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-dependenciesartifactId>
<version>Hoxton.SR3version>
dependency>
dependencies>
dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-netflix-zuulartifactId>
<version>2.2.2.RELEASEversion>
dependency>
<dependency>
<groupId>com.fasterxml.jackson.coregroupId>
<artifactId>jackson-databindartifactId>
<version>2.11.1version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<version>1.18.12version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
<version>2.3.4.RELEASEversion>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>8.0.21version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-jdbcartifactId>
<version>2.3.4.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-actuatorartifactId>
<version>2.3.4.RELEASEversion>
dependency>
dependencies>
启动类:这里需要加入 @EnableZuulProxy
来表示该服务将作为 Zuul 网关来使用。
@SpringBootApplication
@EnableZuulProxy //作为网关
public class ZuulServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ZuulServiceApplication.class,args);
}
}
server:
port: 9527
spring:
application:
name: zuul-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/imooc-security?useSSL=false&useUnicode=true&characterEncoding=UTF8&serverTimezone=Asia/Shanghai
username: root
password: root
# 网关配置
zuul:
# 配置网关路由
routes:
token: #路由到认证服务器
url: http://localhost:9070
order: #路由到订单服务
url: http://localhost:9090
sensitive-headers: #清空敏感头
前面是数据库的配置,不赘述,重点来看一下网关的配置。
这里我们写了两个路由,分别路由到认证服务器 auth-service 和订单服务器 order-service。
重点是这里有个 sensitive-headers
。这是敏感头,默认是 cookie、set-cookie 和 authentication 这 3 个,设为敏感头的话 Zuul 将不会传递它们到下一个过滤器,这里我们需要传递,所以我们将敏感头设为空。
启动 4 个服务,测试一下路由转发是否成功:
我们先来获取 token,因为我们现在设置了网关,所以要访问的是网关的端口 9527,然后要路由到 auth-service,所以后面需要加 /token,然后再加上获取 token 的接口路径,如下:
现在拿 token 来创建订单:
都没有问题。
我们先来回顾之前 API 安全认证那张图:
所以我们第一步要做的就是流控,用 Zuul 网关来做流控的话是非常简单的。我们可以利用一个开源项目 zuul-ratelimit
然后再做简单的配置就可以了。
先导入相关 pom 依赖坐标
<dependency>
<groupId>com.marcosbarbero.cloudgroupId>
<artifactId>spring-cloud-zuul-ratelimitartifactId>
<version>2.2.2.RELEASEversion>
dependency>
加入 Spring Data JPA 依赖
因为 zuul-ratelimit
在做流控的时候,需要将流控相关的信息进行存储,它支持 JPA,也支持 Redis,这里我们存储在数据库(生产上用 redis ),所以引入 JPA:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jpaartifactId>
<version>2.3.4.RELEASEversion>
dependency>
配置数据库和 JPA:
server:
port: 9527
spring:
application:
name: zuul-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/imooc-security?useSSL=false&useUnicode=true&characterEncoding=UTF8&serverTimezone=Asia/Shanghai
username: root
password: root
jpa:
generate-ddl: true
show-sql: true
配置流控相关信息:
# 网关配置
zuul:
# 配置网关路由
routes:
token: #路由到认证服务器
url: http://localhost:9070
order: #路由到订单服务
url: http://localhost:9090
sensitive-headers: #清空敏感头
# 配置 zuul 流控
ratelimit:
enabled: true # 支持流控
repository: JPA # 存到 JPA,生产上是存到 Redis
default-policy-list: # 默认的流控策略(2.4.x 版本的 zuul-limit 好像没有这个,最好使用 2.2.x 低版本的 zuul-limit
- limit: 2
quota: 1 # quota=1 & limit=2 表示在 1 秒中之内可以接受 2 次请求
refresh-interval: 1 # 刷新间隔
type: # 限流的类型
- url # 根据 url 来限流,如 /a,/b
- httpmethod # 根据 httpmethod 来限流,如 get,post
# - user # 根据用户来限流,需要引入 spring security,一般不用
# - origin # 根据用户的 ip 地址来限流
# policy-list: #对每一个路由到的微服务量身定制一个流控策略,而不采用默认的全局流控策略
# token:
# - limit: 10
# quota: 1
# refresh-interval: 1
# type:
这样流控就完成了。
网关的认证授权都是通过过滤器和拦截器来实现的,我们现在开始来写过滤器。
构建 OAuth2 过滤器 OAuth2Filter
@Component
public class OAuth2Filter extends ZuulFilter {
//过滤类型
@Override
public String filterType() {
return null;
}
//过滤器优先级
@Override
public int filterOrder() {
return 0;
}
//是否要进行过滤(即该过滤器是否要生效)
@Override
public boolean shouldFilter() {
return false;
}
//具体的过滤操作 => 认证逻辑
@Override
public Object run() throws ZuulException {
return null;
}
}
Zuul 网关中的过滤器都需要继承 ZuulFilter
这个类,每一个继承该类的过滤器都需要实现 4 个方法:
我们的设置如下:
/**
* 认证
*
* @author Hedon Wang
* @create 2020-10-13 11:47
*/
@Component
public class OAuth2Filter extends ZuulFilter {
//过滤类型
@Override
public String filterType() {
return "pre";
}
//过滤器优先级
@Override
public int filterOrder() {
return 1;
}
//是否要进行过滤(即该过滤器是否要生效)
@Override
public boolean shouldFilter() {
return true;
}
//具体的过滤操作 => 认证逻辑
@Override
public Object run() throws ZuulException {
System.out.println("认证开始=========>>>>>>>");
//获取当前请求的上下文 => 为了获取当前的请求对象
RequestContext requestContext = RequestContext.getCurrentContext();
//获取当前的请求对象
HttpServletRequest request = requestContext.getRequest();
/**
* ============================================
* 下面开始认证
* ============================================
*/
//① 判断请求是否需要认证
if (StringUtils.startsWith(request.getRequestURI(),"/token")){
//以 '/token' 开头的请求是发往认证服务器的,是不需要经过认证的
return null;
}
//② 需要认证的话是否携带认证信息了
String authHeader = request.getHeader("Authorization");
if (StringUtils.isBlank(authHeader)){
//没带信息,往下走,这只是为了审计,后面肯定是走不到底的
return null;
}
//③ 带的认证信息类型是否正确 => 需要以 bearer 开头
if (!StringUtils.startsWithIgnoreCase(authHeader,"bearer ")){
//OAuth2.0 是 bearer 类型的,如果不是,那也继续往下走,然后审计,后面肯定也是走不到底的
return null;
}
//④ 类型正确的话解析后看看是否有权限
try{
//将令牌传到认证服务器去进行鉴别,并将鉴别结果封装到我们自定义的封装类 TokenInfo 中
TokenInfo info = getTokenInfo(authHeader);
//正常获取到 tokenInfo 的话,放到请求域中
request.setAttribute("tokenInfo",info);
}catch (Exception e){
//如果获取 tokenInfo 过程抛出异常
System.out.println("Get token info failed");
}
return null;
}
}
这里面的 TokenInfo
类型和 getTokenInfo()
方法我们都没定义和实现。下面来实现。
TokenInfo 类
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class TokenInfo {
/**
* =================================================
* 必须按照以下字段进行命名,OAuth2.0 才能精准封装
* =================================================
*/
private boolean active; //令牌是否可用
private String client_id; //令牌是发给哪个客户端应用的
private String[] scope; //令牌拥有的对资源服务器的操作权限
private String user_name; //令牌发送给哪个用户
private String[] aud; //令牌可以访问哪些资源服务器
private Date exp; //令牌过期时间
private String[] authorities; //令牌拥有的所有权限信息
}
getTokenInfo():传递 token 去认证服务器进行认证,然后将认证结果封装到 TokenInfo 对象中,在 OAuth2Filter
类中实现:
//将令牌转发到认证服务器去验证,并将验证信息并放到我们自定义的封装类 TokenInfo 中
private TokenInfo getTokenInfo(String authHeader) {
//去掉 token 的前缀 bearer
String token = StringUtils.substringAfter(authHeader,"bearer ");
//认证服务器检查 token 的地址
String oauthServiceCheckTokenUrl = "http://localhost:9070/oauth/check_token";
//封装请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
//网关也是一个客户端,我们也需要将它注册到认证服务器中
headers.setBasicAuth("zuul","123456");
//封装请求实体
MultiValueMap<String,String> params = new LinkedMultiValueMap<>();
params.add("token",token);
HttpEntity<MultiValueMap<String,String>> entity = new HttpEntity<>(params,headers);
/**
* 发送请求
*
* 参数1:请求连接
* 参数2:请求类型
* 参数3:请求数据
* 参数4:请求结果封装到哪里
*/
ResponseEntity<TokenInfo> response = restTemplate.exchange(oauthServiceCheckTokenUrl, HttpMethod.POST, entity, TokenInfo.class);
//拿到响应实体
TokenInfo tokenInfo = response.getBody();
System.out.println("tokenInfo is "+tokenInfo);
return tokenInfo;
}
这里我们首先要取出用户携带的 token,然后指定网关是哪个客户端,将它们封装到一个 HttpEntity 请求实体中,然后发送给认证服务器去进行认证。注意这里我们需要将网关注册到认证服务器里面,在数据库中添加:
认证后面就是审计,现在我们来实现审计的过滤器。
@Component
public class AuditLogFilter extends ZuulFilter {
//在请求进来前执行
@Override
public String filterType() {
return "pre";
}
//在认证后面
@Override
public int filterOrder() {
return 2;
}
@Override
public boolean shouldFilter() {
return true;
}
//打印审计信息
@Override
public Object run() throws ZuulException {
System.out.println("审计开始 =======>>>>>>>");
return null;
}
}
同样如前面所说,Zuul 中的过滤器都要继承 ZuulFilter 并实现它的 4 个方法。我们这里审计就不搞数据库了,就简单输出一句话就行了,意思到位了就行。这里只是在请求进来的时候进行了审计,如果要在请求完成或者请求异常的时候进行审计,其实我们就可以修改上面 filterType
中的值了,设为 post
就是请求完成的时候进行审计,设为 error
就是请求异常的时候进行审计。
授权我们还是写一个过滤器 AuthorizationFilter
,具体实现代码如下:
@Component
public class AuthorizationFilter extends ZuulFilter {
@Override
public String filterType() {
return "pre";
}
//在"进来的审计"后面
@Override
public int filterOrder() {
return 3;
}
@Override
public boolean shouldFilter() {
return true;
}
//授权
@Override
public Object run() throws ZuulException {
System.out.println("授权开始=============>>>>>>>>>>>>>>>>>>");
//拿到当前请求上下文
RequestContext requestContext = RequestContext.getCurrentContext();
//拿到请求体
HttpServletRequest request = requestContext.getRequest();
/**
* ============================================
* 下面开始授权
* ============================================
*/
//① 该请求是否需要权限
if (isNeedAuth(request)){
//② 需要认证,那就拿出 tokenInfo
TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo");
//③ tokenInfo 是否存在,存在是否可用
if (tokenInfo != null && tokenInfo.isActive()){
//④ 认证成功(能拿到用户信息且有效) => 是否有权限
if (hasPermissions(tokenInfo,request)){
//有权限就放行
}else{
//没有权限,记录审计日志
System.out.println("异常审计===============>>>>>403 错误,授权失败....");
handleError(403,requestContext);
}
}else{
/**
* tokenInfo 不存在或者不可用情况一:本来就不需要携带
*/
if (StringUtils.startsWith(request.getRequestURI(),"/token")){
// '/token' 开头的请求放行
}else{
/**
* tokenInfo 不存在或者不可用情况二:需要带来认证
* 不存在:没传 tokenInfo
* 不可用:token 过期或无效
*/
System.out.println("异常审计=============>>>>>401 错误,认证失败...");
//处理异常
handleError(401,requestContext);
}
}
}
/**
* 进到下一个过滤器,3种情况:
*
* 1. isNeedAuth 返回 false,也就是请求不需要认证授权
* 2. 到 ④ 后检查到用户是有权限的,就正常放行
* 3. 请求路径是以 /token 开头的,其实这也是不需要认证授权的情况
*
*/
return null;
}
//判断是否拥有权限
private boolean hasPermissions(TokenInfo tokenInfo,HttpServletRequest request) {
//这里按道理是要查询数据库然后进行对比的,这里我们随意些,就给50%的几率可以通过
return RandomUtils.nextInt() % 2 == 0;
}
//处理异常 => 发送异常信息,并且停止前行
private void handleError(int status, RequestContext requestContext) {
requestContext.getResponse().setContentType("application/json");
requestContext.setResponseStatusCode(status);
requestContext.setResponseBody("{\"message: \": \"auth fail\"}");
//不要往后走了
requestContext.setSendZuulResponse(false);
}
//判断当前请求是否需要认证
private boolean isNeedAuth(HttpServletRequest request) {
//这里简单搞,都要经过认证
return true;
}
}
我们具体来分析一下 run()
方法:
首先我们要先通过 当前请求上下文
来拿到 当前请求 HttpServletRequest
,然后对这个 request 进行认证授权,具体流程如下图:
这样我们整个网关安全的 流控->认证->审计->授权
逻辑就完成啦~ 现在已经可以通过网关来跟认证服务器打交道从而实现业务逻辑和认证逻辑的分离了。
在测试之前,我们先来删除掉 order-service 中跟安全认证相关的代码,来验证我们的 “解耦” 是否真实有效。
删除掉 pom 依赖坐标
删除跟安全认证相关的类
出现一个问题:
删除上面那些类之后就出现了一个问题,之前我们在接口处获取用户信息的方式就无法执行了:
那么我们现在如果想要获取用户相关的信息该怎么做呢?
演示如何获取 username
这里我们演示如何获取 username,当然要获取其他的信息也是同理的。
这里需要从 RequestHeader
中来获取 username:
那么我们在哪里放这个 username 呢?我们需要来到网关授权的地方 AuthorizationFilter
,在我们对用户进行认证并且授权的时候,我们顺带把 username 放到 requestHeader
当中就可以了:
现在我们可以来测试整个网关的安全认证了。
我们先获取 token :
看看后台的日志打印:
没有问题。
我们携带刚刚的 token 来创建订单,发现是没有问题的。现在来看看后台的日志打印:
还可以再来看 order-service 中接口有没有获取到 requestHeader 中的用户信息:
没有问题。
但是注意,我们前面设置了有 50% 的几率会授权不通过的,我们再多试几次,看看不通过的情况:
这就是不通过的情况,认证通过但是授权失败的时候,返回的是 403 错误码,我们来看看后台日志打印:
不携带 token 的话就认证失败了,返回 401,后台:
一样是认证失败。
我们前面还加了流控,现在我们来快速访问创建订单这个接口,看看什么反应:
可以看到,当我们快速访问超过流控限制的时候,会报 429
错误。
至此,整个网关安全的案例就搭建完毕了~ 后面会再继续更新 SSO 单点登录和整合 JWT 令牌等学习笔记,敬请期待~
如果有什么错误的地方,还请批评指正~