Oauth2概述

OAuth2是一个开放的授权协议,它允许用户授权第三方应用访问他们在某个服务上的资源,而无需暴露自己的账号和密码。OAuth2的主要优点是简化了客户端开发,提供了多种适用于不同场景的授权流程,增强了安全性和灵活性。

OAuth2的核心概念包括以下几个:

  • 资源所有者(Resource Owner):拥有资源的用户。
  • 资源服务器(Resource Server):存储用户资源的服务。
  • 客户端(Client):想要访问用户资源的第三方应用,例如一个网站或一个手机应用。
  • 授权服务器(Authorization Server):负责验证用户身份和授权客户端访问资源的服务,例如Google的账号服务。

OAuth2定义了四种授权流程(Grant Type),分别适用于不同类型的客户端:

  • 授权码模式(Authorization Code):适用于有后端服务器的客户端,例如网站应用。客户端先通过用户的浏览器向授权服务器请求一个授权码(Authorization Code),然后再通过后端服务器向授权服务器交换一个访问令牌(Access Token),最后使用访问令牌向资源服务器请求资源。
  • 简化模式(Implicit):适用于没有后端服务器的客户端,例如单页面应用(SPA)。客户端直接通过用户的浏览器向授权服务器请求一个访问令牌,然后使用访问令牌向资源服务器请求资源。这种模式比较简单,但也比较不安全,因为访问令牌可能会被截获或泄露。
  • 客户端凭证模式(Client Credentials):适用于只需要访问自己拥有的资源的客户端,例如后台服务。客户端直接向授权服务器提供自己的凭证(Client ID和Client Secret),然后获取一个访问令牌,最后使用访问令牌向资源服务器请求资源。
  • 密码模式(Password):适用于用户对客户端高度信任的情况,例如自己开发的应用。客户端直接向授权服务器提供用户的账号和密码,然后获取一个访问令牌,最后使用访问令牌向资源服务器请求资源。这种模式比较方便,但也比较不安全,因为用户的账号和密码可能会被泄露或滥用。

项目环境

项目中使用到的一些技术栈:

技术版本号
Spring Cloud2021.0.1
Spring Cloud Alibaba2021.0.1.0
Spring Boot2.7.0
Nacos2.0.4
MyBatis-Plus3.5.3.1
Lombok1.18.24
JDK8
MySQL8.0
Redis-

项目结构

  • common - 通用组件服务,包含一些共享的工具类、通用模型等。
  • gateway - 网关服务,负责请求路由、过滤、鉴权功能。
  • oauth2-service - 提供Oauth2认证服务的服务、用户注册、登录。
  • user-service - 提供用户管理功能的服务,如查询用户信息等接口。

授权码模式执行流程

  1. 客户端需要登录跳转到认证中心并且在url携带client_id和redirect_url
  2. 登录完成后,重定向到 redirect_url并且携带code
  3. 使用 code 到授权服务器换取 access_token 和 refresh_token(这一步是在客户端服务器完成,因为需要使用code,client_id和client_secret去交换token,而client_secret是不允许公开的)
  4. 客户端使用 access_token 访问获取用户信息
  5. 过期后使用 refresh_token 刷新 token 再次使用

项目搭建

父pom文件

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cc.oolo</groupId>
    <artifactId>myCode</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <artifactId>spring-boot-starter-parent</artifactId>
        <groupId>org.springframework.boot</groupId>
        <version>2.7.0</version>
        <relativePath />
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

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

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

            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>2021.0.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

common模块

创建通用模块common-core:主要定义了一个通用的返回实体类ResultBody

oauth2-service模块

创建oauth2-service模块:

oauth2-service pom文件

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>myCode</artifactId>
        <groupId>cc.oolo</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>oauth2</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>

        <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>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>

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

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


        <dependency>
            <groupId>cc.oolo</groupId>
            <artifactId>common-core</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

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

        <!-- oauth2 需要依赖持久层。username+password。clientid + clientsecret token 暂时存储在数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
    </dependencies>

</project>

yml配置文件

server:
  port: 8500
  # 服务将在 8500 端口上运行

spring:
  application:
    name: oauth2-service
    # 应用的名称是 gateway-service
  redis:
    port: 6379
    password: 123456
    host: localhost

  main:
    allow-bean-definition-overriding: true
    # 如果一个已经存在的bean定义(有相同的名字)被再次定义,那么允许覆盖这个bean定义

  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        # 服务发现的 Nacos 服务器地址
        username: nacos
        # Nacos 的用户名
        password: nacos
        # Nacos 的密码

      config:
        server-addr: localhost:8848
        # 配置的 Nacos 服务器地址
        file-extension: yaml
        # 配置文件的格式是 yaml
        username: nacos
        # Nacos 的用户名
        password: nacos
        # Nacos 的密码
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/oauth?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
  1. 准备数据表

https://cdn.oolo.cc//source/tables.sql

  1. 准备用户认证服务

这个类的作用是实现用户身份认证。在用户登录时,该类会查询数据库中对应的用户信息,并将其转换为Spring Security框架中的UserDetails对象。然后,框架会使用该对象进行用户身份认证,判断用户输入的用户名和密码是否匹配。

package cc.oolo.oauth.service;


import cc.oolo.oauth.mapper.UserMapper;
import cc.oolo.oauth.pojo.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
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 javax.annotation.Resource;
import java.util.Collections;
import java.util.Objects;

@Service
public class UserService implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username",username);
        User user = userMapper.selectOne(queryWrapper);
        if (Objects.isNull(user)){
            throw new UsernameNotFoundException("用户名不存在");
        }
        return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(),
                Collections.emptyList());
    }
}
  1. 定义授权服务器

在搭建授权服务器时,我们需要配置各个组件来处理认证、授权、令牌管理等功能。

在config包下创建 Oauth2Config 类,首先,我们需要配置Token的存储方式,Spring Security提供了基于数据库JdbcTokenStore和RedisRedisTokenStore两种实现,选择其中一种:

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

// 配置RedisTokenStore 
@Bean
public TokenStore tokenStore(){
  return new RedisTokenStore(redisConnectionFactory);
}

然后,我们需要配置授权码服务AuthorizationCodeServices,授权码Spring Security只提供了 JdbcAuthorizationCodeServices ,也就是基于数据库存储,如果要使用 Redis 存储,需要我们手动重写方法

使用数据库存储授权码:

    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }

使用Redis存储授权码:

因为Spring Security 并没有提供将授权码存储在 Redis 的类,所以需要我们自定义一个类,并且实现 AuthorizationCodeServices 接口

package cc.oolo.oauth.service;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;

import javax.annotation.Resource;
import java.util.UUID;

public class RedisAuthorizationCodeServices implements AuthorizationCodeServices {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public String createAuthorizationCode(OAuth2Authentication authentication) {
        String code = generateAuthorizationCode(); // 生成授权码
        String key = "oauth2:authorization_code:" + code;
        redisTemplate.opsForValue().set(key, authentication); // 将授权码存储在 Redis 中
        return code;
    }

    @Override
    public OAuth2Authentication consumeAuthorizationCode(String code) {
        String key = "oauth2:authorization_code:" + code;
        OAuth2Authentication authentication = (OAuth2Authentication) redisTemplate.opsForValue().get(key); // 从 Redis 中检索授权码
        redisTemplate.delete(key); // 从 Redis 中删除已使用的授权码
        return authentication;
    }

    private String generateAuthorizationCode() {
        // 生成授权码的逻辑,可以使用 UUID 等方式生成唯一的授权码
        String code  = UUID.randomUUID().toString();

        // 返回生成的授权码
        return code;
    }
}

然后 创建Bean

    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new RedisAuthorizationCodeServices();
    }

配置DefaultTokenServices

DefaultTokenServices用于设置token的有效期、是否支持刷新等参数:

@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
  DefaultTokenServices services = new DefaultTokenServices();
  services.setTokenStore(tokenStore());
  services.setAccessTokenValiditySeconds(expire);
  services.setSupportRefreshToken(true);
  services.setReuseRefreshToken(false);
  return services;
}

配置ClientDetailsService

ClientDetailsService用于从数据库加载客户端信息,secret_id使用Bcrypt加密

@Bean
public ClientDetailsService clientDetailsService(DataSource dataSource) {
  JdbcClientDetailsService service = new JdbcClientDetailsService(dataSource);
  service.setPasswordEncoder(new BCryptPasswordEncoder());
  return service; 
}

配置AuthorizationServerEndpointsConfigurer

注入endpoint所需的组件服务:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
  endpoints.userDetailsService(userService);
  endpoints.tokenServices(defaultTokenServices());
  endpoints.authenticationManager(authenticationManager);
  endpoints.authorizationCodeServices(authorizationCodeServices());
}

配置AuthorizationServerSecurityConfigurer

设置token端点的访问控制:

@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .checkTokenAccess("permitAll()")
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients();
}

配置ClientDetailsServiceConfigurer

设置客户端信息来源为数据库:

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

完整代码

package cc.oolo.oauth.config;

import cc.oolo.oauth.service.RedisAuthorizationCodeServices;
import cc.oolo.oauth.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.code.JdbcAuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.annotation.Resource;
import javax.sql.DataSource;

@Configuration
@EnableAuthorizationServer
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {
    @Resource
    private DataSource dataSource;

    @Resource
    private UserService userService;

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private RedisConnectionFactory redisConnectionFactory;

    public static final int expire = 30 * 24 * 3600;

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

    @Bean
    public TokenStore tokenStore(){
        return new RedisTokenStore(redisConnectionFactory);
    }

//    @Bean
//    public AuthorizationCodeServices authorizationCodeServices() {
//        return new JdbcAuthorizationCodeServices(dataSource);
//    }

    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new RedisAuthorizationCodeServices();
    }

    @Primary
    @Bean
    public DefaultTokenServices defaultTokenServices() {
        DefaultTokenServices services = new DefaultTokenServices();
        services.setTokenStore(tokenStore());
        services.setAccessTokenValiditySeconds(expire);
        services.setSupportRefreshToken(true);
        services.setReuseRefreshToken(false);
        return services;
    }

    @Bean
    public ClientDetailsService clientDetailsService(DataSource dataSource) {
        JdbcClientDetailsService service = new JdbcClientDetailsService(dataSource);
        service.setPasswordEncoder(new BCryptPasswordEncoder());
        return service;
    }


    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.userDetailsService(userService);
        endpoints.tokenServices(defaultTokenServices());
        endpoints.authenticationManager(authenticationManager);
        endpoints.authorizationCodeServices(authorizationCodeServices());
    }


    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .checkTokenAccess("permitAll()")
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients();
    }


    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource).passwordEncoder(new BCryptPasswordEncoder());
    }
}
  1. Spring Security 配置类

创建一个配置类,用来定义我们的安全策略。这个配置类需要使用@Configuration和@EnableWebSecurity两个注解来标记,表示这是一个Spring的配置类,并且启用了Spring Security的功能。然后,我们需要让这个配置类继承WebSecurityConfigurerAdapter这个抽象类,这个类提供了一些默认的配置方法,我们可以根据需要进行重写。

配置AuthenticationManager:

AuthenticationManager是一个接口,负责处理用户的认证请求。我们可以通过重写authenticationManager()方法来创建一个AuthenticationManager的Bean,供其他地方使用。这个方法调用了父类的authenticationManager()方法,返回一个默认的AuthenticationManager实例。

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

配置PasswordEncoder:

使用BCrypt进行加密

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

重写configure(AuthenticationManagerBuilder auth)方法,配置用户认证的管理器,使用BCryptPasswordEncoder对密码进行加密和匹配

@Resource
private UserService userService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userService)
            .passwordEncoder(new PasswordEncoder() {
                @Override
                public String encode(CharSequence rawPassword) {
                    return passwordEncoder().encode(rawPassword);
                }

                @Override
                public boolean matches(CharSequence rawPassword, String encodedPassword) {
                    return passwordEncoder().matches(rawPassword,encodedPassword);
                }
            });
}

配置 WebSecurity

重写configure(WebSecurity webSecurity)方法来配置WebSecurity。使用ignoring(),指定了哪些请求不需要经过Spring Security的过滤器链

@Override
public void configure(WebSecurity webSecurity) {
    // /login和register不需要认证
    //webSecurity.ignoring()
            //.antMatchers("/login")
            //.antMatchers("/register");
}

配置HttpSecurity

重写configure(HttpSecurity httpSecurity)方法来配置HttpSecurity,authorizeHttpRequests()方法,指定了每个请求需要满足什么条件才能访问,anyRequest().authenticated()方法,表示所有请求都需要认证才能访问。httpBasic()方法,指定了使用HTTP Basic认证方式。最后使用了cors()和csrf().disable()两个方法,分别启用了跨域资源共享和禁用了跨站请求伪造防护。

@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.authorizeHttpRequests().anyRequest().authenticated().and()
            .httpBasic()
            .and().cors()
            .and().csrf().disable();
}

完整配置:

package cc.oolo.oauth.config;

import cc.oolo.oauth.service.UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.annotation.Resource;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private UserService userService;

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

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService)
                .passwordEncoder(new PasswordEncoder() {
                    @Override
                    public String encode(CharSequence rawPassword) {
                        return passwordEncoder().encode(rawPassword);
                    }

                    @Override
                    public boolean matches(CharSequence rawPassword, String encodedPassword) {
                        return passwordEncoder().matches(rawPassword,encodedPassword);
                    }
                });
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        // /login和register不需要认证
        //webSecurity.ignoring()
                //.antMatchers("/login")
                //.antMatchers("/register");
    }

    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.authorizeHttpRequests().anyRequest().authenticated().and()
                .httpBasic()
                .and().cors()
                .and().csrf().disable();
    }
}

测试

数据库中oauth_client_details对应数据

启动oauth2-service服务

在浏览器访问:

localhost:8500/oauth/authorize?client_id=client1&response_type=code

浏览器会直接提示登录

登录完成后,就会跳转到授权页面

点击Authorize授权过后,就会跳转到数据库中指定的回调地址,并且携带code

拿到code过后,再由客户端后端使用client_id和client_secret去换取token

这里使用postman演示:

拿到token后交给客户端保存,如果access_token过期,可以使用refresh_token进行续期,例如:

自定义认证授权页面

引入 thymeleaf 依赖

 <!-- thymeleaf 模板引擎-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

默认提供的登录和授权页面过于丑陋,我们可以自定义登录和授权页面

首先在resource下创建static目录,然后创建login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>请登录</title>
    <link href="https://cdn.oolo.cc//source/cdn.jsdelivr.net_npm_tailwindcss@2.2.19_dist_tailwind.min.css" rel="stylesheet">
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: #edf2f7;
        }
    </style>
</head>
<body>
<div class="w-full max-w-xs">
    <form class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" method="post" action="/login">
        <div class="mb-4">
            <label class="block text-gray-700 text-sm font-bold mb-2" for="username">
                用户名
            </label>
            <input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="text" placeholder="Username" name="username" required autofocus>
        </div>
        <div class="mb-6">
            <label class="block text-gray-700 text-sm font-bold mb-2" for="password">
                密码
            </label>
            <input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="password" type="password" placeholder="******************" name="password" required>
        </div>
        <div class="flex items-center justify-between">
            <button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
                登录
            </button>
        </div>
        <div id="error-message" style="display: none;">
            <span class="text-red-500 text-xs italic">用户名或密码错误</span>
        </div>
    </form>
</div>
</body>
<script>
    window.onload = function() {
        var urlParams = new URLSearchParams(window.location.search);
        if (urlParams.has('error')) {
            document.getElementById('error-message').style.display = 'block';
        }
    };
</script>
</html>

修改 WebSecurityConfig 中 HttpSecurity 规则,其中loginProcessingUrl 登录页面中表单提交的接口,也是Spring Security提供的接口,一开始我以为这个/login是需要自己定义接口,结果是由Spring Security提供的,根本不会走自己的登录接口逻辑。

@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin() //启用 form login 方式.
            .loginPage("/login.html") //自定义登录页面的 URL.
            .loginProcessingUrl("/login") //登录处理接口.
            .permitAll() //允许所有用户访问登录页面和处理接口.
            .and()
            .authorizeHttpRequests() //开始配置权限.
            .antMatchers("/login.html").permitAll() //匹配 "/login.html" 路径,用户可以无条件访问.
            .anyRequest().authenticated() //所有其他请求都需要经过认证.
            .and().httpBasic() //启用 http 基本验证.
            .and().cors() //启用 CORS (跨源资源共享), 默认配置即可.
            .and().csrf().disable(); //禁用 CSRF (跨站请求伪造) 支持.
}

在Spring Security OAuth2 中 /oauth/confirm_access 端点就是用于显示授权页面和处理用户的授权决策的。当用户达到这个端点时,他们将看到一个页面,询问他们是否愿意授权应用访问他们的数据,所以我们可以写一个接口用于跳转到自己的授权页面,并且在配置中将 /oauth/confirm_access 修改为自己的接口

在 resource/templates 下创建授权页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>授权</title>
    <link href="https://cdn.oolo.cc//source/stackpath.bootstrapcdn.com_bootstrap_5.0.0-alpha1_css_bootstrap.min.css" rel="stylesheet">
    <style>
        body {
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            background: #f5f5f5;
        }
        .card {
            width: 90%;
            max-width: 500px;
            box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1); /* Add shadow to the card */
            border-radius: 15px; /* Round the corners of the card */
        }
        .card-header {
            font-size: 1.5rem;
            background: #007BFF; /* Change the header color to match the button color */
            color: white; /* Make the text in the header white */
            border-radius: 15px 15px 0 0; /* Round the corners of the header */
        }
        .card-body {
            padding: 2rem; /* Add more padding to the body */
        }
        .btn-primary {
            border-radius: 20px; /* Round the corners of the button */
            padding: .75rem 1.5rem; /* Add more padding to the button */
        }
    </style>
</head>
<body>
<div class="card">
    <div class="card-header text-center">
        <i class="fas fa-unlock-alt"></i> OAUTH 授权
    </div>
    <div class="card-body">
        <h5 class="card-title" th:text="${clientId}+' 请求授权,该应用将获取你的以下信息'"></h5>
        <th:block th:each="item:${scopes}">
            <p class="card-text" th:text="${item}"></p>
        </th:block>
        <p class="card-text">授权后表明你已同意 <a href="#boot" style="color: #007BFF">OAUTH 服务协议</a></p>
        <form method="post" action="/oauth/authorize">
            <input type="hidden" name="user_oauth_approval" value="true">
            <div th:each="item:${scopes}">
                <input type="radio" th:name="'scope.'+${item}" value="true" hidden="hidden" checked="checked"/>
            </div>
            <button class="btn btn-primary btn-block mt-4" type="submit"> 同意/授权</button>
        </form>
    </div>
</div>
</body>
</html>

创建 GrantController

package cc.oolo.oauth.controller;

import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.servlet.ModelAndView;
import java.util.Map;


@Controller
@SessionAttributes("authorizationRequest")
public class GrantController {

    @RequestMapping("/custom/confirm_access")
    public ModelAndView getAccessConfirmation(Map<String, Object> model) {
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
        ModelAndView view = new ModelAndView();
        view.setViewName("grant");
        view.addObject("clientId", authorizationRequest.getClientId());
        view.addObject("scopes",authorizationRequest.getScope());
        return view;
    }

}

修改 Oauth2Config 中,AuthorizationServerEndpointsConfigurer 的规则

添加:endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access");

将从 /oauth/confirm_access 端点认证,修改为从 /custom/confirm_access 认证

@Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.userDetailsService(userService);
        endpoints.tokenServices(defaultTokenServices());
        endpoints.authenticationManager(authenticationManager);
        endpoints.authorizationCodeServices(authorizationCodeServices());
        endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access");
    }

演示:

首先访问以下链接,将跳转到登录页面

http://localhost:8500/oauth/authorize?client_id=client2&response_type=code&scope=web&redirect_uri=http://redirect2.uri

登录后跳转到授权页面

点击授权后,重定向到传入的redirect_uri,并且携带code

gif 演示:

使用分布式Session

在OAuth2中,认证中心(Authorization Server)负责处理用户的身份验证和授权应用程序访问用户数据的请求。在用户第一次登录时,认证中心会在服务器端创建一个Session,并将对应的Session ID存储在用户的浏览器Cookie中。

在单体项目中,使用这种方式没有什么问题,但是在分布式项目中,用户首先登录,登录成功后将Session ID保存在Cookie,后端创建一个Session。如果用户再一次访问,可能请求会被负载均衡到另外一台服务器上,那一台服务器并没有创建Session,所以就需要重新登录。

为了解决这个问题,我们可以使用基于redis的分布式Session,

方法很简单,只需要引入 spring-session-data-redis 依赖

    <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

然后 在启动类上添加 @EnableRedisHttpSession

再次访问,session已经存储在了redis中

自定义 CustomUserDetails

我们之前在重写loadUserByUsername的时候,返回的类型为 userdetails 包下的User,这个类是由Spring Security 定义的

返回 User 对象可能并不包含我们所需要的一些字段,比如用户id,而我们往往都是使用用户id来查询用户的信息的。

我们查看 UserDetails,发现是一个接口,并且 org.springframework.security.core.userdetails.User 也实现了这个接口所以,我们可以自定义一个实体类,实现这个接口,我们也可以添加一些其他属性

package cc.oolo.oauth.pojo;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.Set;

@Data
public class CustomUserDetails implements UserDetails {
    private String userId;
    private String username;
    private String password;
    private int age;
    private String address;
    private String email;
    private String phone;
    private String birthday;
    private String createAt;
    private String updateAt;
    private String lastLogin;
    private int isEnabled;
    private Set<GrantedAuthority> authorities;


    public CustomUserDetails() {
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @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 isEnabled==1;
    }


}

其中 isAccountNonExpired、isAccountNonlocked、isCredentialsNonExpired 方法,如果没有其需求,一定要设置为true,否则所有用户登录就会失败。

然后根据用户名 查询用户的信息和用户的角色

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cc.oolo.oauth.mapper.UserMapper">

    <select id="getUserByUsername" resultMap="userResultMap" parameterType="String">
        SELECT u.*, r.name AS role_name FROM user u
        LEFT JOIN user_role ur ON u.id = ur.user_id
        LEFT JOIN role r ON r.id = ur.role_id WHERE u.username = #{username}
    </select>

    <resultMap id="userResultMap" type="cc.oolo.oauth.pojo.User" autoMapping="true">
        <id property="userId" column="id"></id>
        <collection property="roles" javaType="java.util.ArrayList" ofType="java.lang.String" >
            <result column="role_name" />
        </collection>
    </resultMap>

</mapper>

修改 loadUserByUsername 方法

package cc.oolo.oauth.service;


import cc.oolo.oauth.mapper.UserMapper;
import cc.oolo.oauth.pojo.CustomUserDetails;
import cc.oolo.oauth.pojo.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration;
import org.springframework.security.core.GrantedAuthority;
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 javax.annotation.Resource;
import java.util.*;

@Service
public class UserService implements UserDetailsService {
    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        //查询用户
        User user = userMapper.getUserByUsername(username);
        if (Objects.isNull(user)){
            throw new UsernameNotFoundException("用户名不存在");
        }

        //查询用户权限
        Set<GrantedAuthority> authorityList = new HashSet<>();
        for (String role : user.getRoles()) {
            authorityList.add(new SimpleGrantedAuthority(role));
        }

        CustomUserDetails customUserDetails = new CustomUserDetails();
        customUserDetails.setAuthorities(authorityList);
        BeanUtils.copyProperties(user,customUserDetails);
//         return new org.springframework.security.core.userdetails.User(user.getUsername(),user.getPassword(), Collections.emptyList());
        return customUserDetails;
    }
}

创建 CustomTokenEnhancer,并继承 TokenEnhancer

将 userId 添加到redis中 access_token 对应的 value 中,后续可以通过access_token 获取到用户的数据

package cc.oolo.oauth.enhancer;

import cc.oolo.oauth.pojo.CustomUserDetails;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component
public class CustomTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> additionalInfo = new HashMap<>();
        Map<String, Object> userInfo = new HashMap<>();
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
         //添加用户信息
        additionalInfo.put("userId", userDetails.getUserId());
        additionalInfo.put("age",userDetails.getAge());
        additionalInfo.put("address",userDetails.getAddress());
        additionalInfo.put("email",userDetails.getEmail());
        additionalInfo.put("phone",userDetails.getPhone());
        additionalInfo.put("birthday",userDetails.getBirthday());
        additionalInfo.put("createAt",userDetails.getCreateAt());
        additionalInfo.put("updateAt",userDetails.getUpdateAt());
        additionalInfo.put("lastLogin",userDetails.getLastLogin());
        additionalInfo.put("isEnabled",userDetails.isEnabled());
        additionalInfo.put("authorities",userDetails.getAuthorities());
        userInfo.put("user_info",userDetails);
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(userInfo);
        return accessToken;
    }
}

然后将 customTokenEnhancer 注册到 DefaultTokenServices 中

自定义 /oauth/token 和 /oauth/check_token 返回格式

在调用/oauth/token 或 /oauth/check_token接口的时候,除了会返回 token 的信息外,还会将 additionalInfo 的数据一起返回,如图所示

所以我们需要自定义返回格式以及返回的内容,避免敏感数据泄漏

创建 AuthController,通过 TokenEndpoint 和 CheckTokenEndpoint 拿到 /oauth/token 和 /oauth/check_token 的返回结果,然后处理敏感数据后,再进行返回,

package cc.oolo.oauth.controller;

import cc.oolo.common.domain.ResultBody;
import cc.oolo.oauth.pojo.Oauth2TokenDto;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.CheckTokenEndpoint;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.security.Principal;
import java.util.Map;

@RestController
@RequestMapping("/oauth")
public class AuthController {

    @Resource
    private TokenEndpoint tokenEndpoint;

    @Resource
    private CheckTokenEndpoint checkTokenEndpoint;

    /**
     * Oauth2 登录认证
     */
    @PostMapping("/token")
    public ResultBody postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
        OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
        Oauth2TokenDto result = Oauth2TokenDto.builder()
                .access_token(oAuth2AccessToken.getValue())
                .refresh_token(oAuth2AccessToken.getRefreshToken().getValue())
                .expiresIn(oAuth2AccessToken.getExpiresIn())
                .build();
        return ResultBody.success(result);
    }

    @GetMapping("/check_token")
    public ResultBody postCheckToken(@RequestParam("access_token") String accessToken) throws HttpRequestMethodNotSupportedException {
        Map<String, ?> stringMap = checkTokenEndpoint.checkToken(accessToken);
        stringMap.remove("user_info");
        stringMap.remove("authorities");
        stringMap.remove("user_name");
        return ResultBody.success(stringMap);
    }
}

测试:

访问 /oauth/token 接口使用 code 换取 token:

访问 /oauth/check_token 接口 检查token是否有效

创建 user-service 服务

创建 user-service 服务,提供获取用户信息的接口

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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>myCode</artifactId>
        <groupId>cc.oolo</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>user-service</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>


        <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>
        </dependency>

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

        <dependency>
            <groupId>cc.oolo</groupId>
            <artifactId>oauth2</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

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


        <dependency>
            <groupId>cc.oolo</groupId>
            <artifactId>common-utils</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!-- oauth2 需要依赖持久层。username+password。clientid + clientsecret token存起来
        先用mysql做持久层,后续升级成redis-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
    </dependencies>

</project>

application.yml文件

server:
  port: 9001
  # 服务将在 9001 端口上运行

spring:
  application:
    name: user-service
    # 应用的名称是 user-service
  redis:
    port: 6379
    password: 123456
    host: localhost

  main:
    allow-bean-definition-overriding: true
    # 如果一个已经存在的bean定义(有相同的名字)被再次定义,那么允许覆盖这个bean定义

  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        # 服务发现的 Nacos 服务器地址
        username: nacos
        # Nacos 的用户名
        password: nacos
        # Nacos 的密码

      config:
        server-addr: localhost:8848
        # 配置的 Nacos 服务器地址
        file-extension: yaml
        # 配置文件的格式是 yaml
        username: nacos
        # Nacos 的用户名
        password: nacos
        # Nacos 的密码
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/oauth?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456

在 user-service中,获取用户信息,只需要通过 access_token 从redis中获取用户信息即可,所以我们只需要使用 TokenStore 的 readAuthentication 方法,就可以拿到 OAuth2Authentication,然后调用 oAuth2AccessToken.getUserAuthentication().getPrincipal() 方法,就可以拿到用户的数据了。

创建 TokenStoreConfig

package cc.oolo.user.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.annotation.Resource;

@Configuration
public class TokenStoreConfig {
    @Resource
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }

}

创建 Oauth2Config

package cc.oolo.user.config;

import org.springframework.context.annotation.Configuration;
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.token.store.redis.RedisTokenStore;

import javax.annotation.Resource;

@Configuration
@EnableAuthorizationServer
public class Oauth2Config extends AuthorizationServerConfigurerAdapter {

    @Resource
    private RedisTokenStore tokenStore;

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.tokenStore(tokenStore);
    }
}

创建 UserController

我们定义了两个接口,一个是前端可以访问的接口,将返回用户的一些非敏感数据

另外一个是服务内获取用户信息使用的接口,将返回用户的所有数据

其中接口url的前缀:

  • pb(public):所有请求均可访问,不需要进行任何认证或授权。
  • pt(protected):需要进行 Token 认证后才能访问的资源。请求必须携带有效的 Token,并且 Token 必须通过认证才能访问受保护的资源。
  • pv(private):无法通过网关直接访问的资源,只能在微服务内部调用。这些资源对外部客户端不可见,只能由其他微服务内部进行调用。
  • df(default):网关对请求进行 Token 认证,并且对请求参数和返回结果进行加解密处理。这种模式通常用于保护敏感数据的传输,确保数据在传输过程中的安全性。

这样定义的好处是,我们可以在网关直接根据url判断目标接口是否可以被前端直接调用

package cc.oolo.user.controller;

import cc.oolo.common.domain.ResultBody;
import cc.oolo.common.domain.User;

import cc.oolo.oauth.pojo.PublicUserInfo;
import org.springframework.beans.BeanUtils;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
public class UserController {

    @Resource
    private RedisTokenStore tokenStore;

    /**
     * 常规信息,不包含敏感信息,可由前端调用
     * @param accessToken
     * @return
     */
    @GetMapping("/pb/getUserInfo")
    public ResultBody getUserInfo(@RequestParam("access_token") String accessToken){
        User user = getUserByToken(accessToken);
        PublicUserInfo publicUserInfo = new PublicUserInfo();
        BeanUtils.copyProperties(user,publicUserInfo);
        return ResultBody.success(publicUserInfo);
    }

    /**
     * 包含用户所有信息
     * @param accessToken
     * @return
     */
    @GetMapping("/pv/getUserAll")
    public User getUser(@RequestParam("access_token") String accessToken){
        User user = getUserByToken(accessToken);
        return user;
    }

    public User getUserByToken(String accessToken){
        OAuth2Authentication oAuth2AccessToken = tokenStore.readAuthentication(accessToken);
        User user = new User();
        CustomUserDetails principal = (CustomUserDetails) oAuth2AccessToken.getUserAuthentication().getPrincipal();
        List<String> roles = new ArrayList<>();
        for (GrantedAuthority authority : principal.getAuthorities()) {
            roles.add(authority.getAuthority());
        }
        user.setRoles(roles);
        BeanUtils.copyProperties(principal,user);
        return user;
    }
}

测试:

搭建 gateway 网关

配置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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>myCode</artifactId>
        <groupId>cc.oolo</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>

    <artifactId>gateway</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

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

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

        <!--        spring cloud gateway,如果想要进行请求的转发,在我们当前使用的2021.0.1.0版本,必须引入loadbalance,否则报错503-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>

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

        <dependency>
            <groupId>cc.oolo</groupId>
            <artifactId>common-utils</artifactId>
            <version>1.0-SNAPSHOT</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-web</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>
</project>

配置 application.yml

server:
  port: 9090
  # 服务将在 9090 端口上运行

spring:
  application:
    name: gateway-service
    # 应用的名称是 gateway-service
  redis:
    port: 6379
    password: s5YHyEsFSAWrZkzCRfbK
    host: 101.5.10.189


  main:
    allow-bean-definition-overriding: true
    # 如果一个已经存在的bean定义(有相同的名字)被再次定义,那么允许覆盖这个bean定义

  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        # 服务发现的 Nacos 服务器地址
        username: nacos
        # Nacos 的用户名
        password: nacos
        # Nacos 的密码

      config:
        server-addr: localhost:8848
        # 配置的 Nacos 服务器地址
        file-extension: yaml
        # 配置文件的格式是 yaml
        username: nacos
        # Nacos 的用户名
        password: nacos
        # Nacos 的密码

    gateway:
      discovery:
        locator:
          enabled: true
          # 启用通过服务发现自动创建路由的功能

      routes:
        - id: user-service-routes
          uri: lb://user-service
          predicates:
            - Path=/**
          filters:
            - StripPrefix=1

配置 Openfeign 和 拦截器

我们需要在网关首先需要进行 url 放行(过滤),如果没有被放行的 url ,首先会进行 token 空判断,如果为空则直接返回错误,然后再通过 openfeign,访问 Oauth2-service 服务的 /oauth/check_token 接口检查 token是否有效,如何有效则根据token获取用户的信息,将用户的信息放入请求体,传递给下游服务,下图为调用 /oauth/check_token 接口的返回结果

其中 active 代表token是否有效

创建 Oauth2FeignClient,用于请求 Oauth2-service 服务的 /oauth/check_token 和 /getUserAll 接口

package oolo.cc.gateway.feign;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.Map;

@FeignClient("oauth2-service")
public interface Oauth2FeignClient {

    @RequestMapping("/oauth/check_token")
    Map<String,Object> checkToken(@RequestParam("access_token") String accessToken);

    @GetMapping("/pv/getUserAll")
    ResultBody getUserAll(@RequestParam("access_token") String accessToken);
}

创建 Oauth2Filter

package oolo.cc.gateway.filter;

import cc.oolo.common.domain.ResultBody;
import cc.oolo.common.domain.User;
import cc.oolo.common.utils.ServletUtils;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Maps;
import io.netty.util.internal.StringUtil;
import lombok.SneakyThrows;
import oolo.cc.gateway.feign.Oauth2FeignClient;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;

@Order(0)
@Component
public class Oauth2Filter implements GlobalFilter {

    @Resource
    @Lazy
    private Oauth2FeignClient oauth2FeignClient;


    @SneakyThrows
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        String path = request.getURI().getPath();
  
        // 放行指定url
        if (path.matches("^/oauth.*") || path.equals("/getUserInfo")) {
            return chain.filter(exchange);
        }

        // 判断 接口 是否可以被前端访问
        if (path.contains("/pv/")){
            return unauthorizedResponse(exchange,"该接口只允许系统内部访问");
        }

        // token 校验
        String token = request.getHeaders().getFirst("Authorization");
        if (StringUtil.isNullOrEmpty(token)){
            return unauthorizedResponse(exchange, "令牌不能为空");
        }

        // 检查 access_token 是否有效,必须使用异步执行,否则会报错
        CompletableFuture<ResultBody> checkFuture = CompletableFuture.supplyAsync(() -> {
            return oauth2FeignClient.checkToken(token);
        });
        ResultBody resultBody = checkFuture.get();
        Map<String,Object> map = (Map<String, Object>) resultBody.getData();
        boolean active = (boolean)map.get("active");
        if (!active){
            return unauthorizedResponse(exchange, "令牌已失效,请重新登录");
        }

        // 根据 access_token 获取用户信息,将用户信息写入请求头转发给下游服务
        CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> {
            return oauth2FeignClient.getUserAll(token);
        });
        User user = userFuture.get();
        String traceId = UUID.randomUUID().toString();
        ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders -> {
            httpHeaders.set("userInfo", JSONObject.toJSONString(user));
            httpHeaders.set("traceId", traceId);
        }).build();
        exchange.mutate().request(serverHttpRequest);
        return chain.filter(exchange);
    }

    private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String msg) {
        return ServletUtils.webFluxResponseWriter(exchange.getResponse(), msg, HttpStatus.UNAUTHORIZED.value());
    }
}

当调用方法的时候报错

feign.codec.EncodeException: No qualifying bean of type 'org.springframework.boot.autoconfigure.http.HttpMessageConverters' 
available: expected at least 1 bean which qualifies as autowire candidate.
Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}

因为Spring Cloud Gateway是基于WebFlux的,是ReactiveWeb,所以HttpMessageConverters不会自动注入。在gateway服务中配置以下Bean,即可解决。

package oolo.cc.gateway.config;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;

import java.util.stream.Collectors;

@Configuration
public class ConvertersConfig {
    @Bean
    @ConditionalOnMissingBean
    public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
        return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
    }
}

测试:

授权

常用的授权方式有两种,第一种是在各自的服务进行授权,第二种是在网关进行统一授权

一、用户和接口权限授权
用户和接口权限授权是指根据用户和接口的权限信息进行授权判断。每个服务都有自己的授权逻辑,根据请求中的用户身份和接口权限,决定是否允许请求通过。

二、网关统一授权
网关统一授权是指将授权逻辑集中在系统的网关层进行处理。当用户发起请求时,所有的授权判断都在网关上完成,网关根据用户的身份和权限信息,决定是否允许请求通过并将请求转发给相应的服务,前提条件是,需要提前将接口和权限的映射关系保存在网关。

这里采用网关统一授权的方式:

首先需要存储接口 url 对应的权限映射关系

    private static final Map<String, String> AUTH_MAP = Maps.newConcurrentMap();

    // 此处映射关系可以从数据库中获取
    @PostConstruct
    public void initAuthMap() {
        AUTH_MAP.put("/getUserList", "admin");
    }

在根据 access_token 获取到用户信息过后,根据获取到的用户信息和当前访问url对应的权限对比:

List<String> roles = user.getRoles();
        if (authorities != null && !roles.contains(authorities)) {
            return unauthorizedResponse(exchange, "没有权限访问");
        }

如果没有权限拒绝访问即可

最后修改:2023 年 11 月 16 日
如果觉得我的文章对你有用,请随意赞赏