摘要

本文档全程采用校园生活类比,将复杂的微服务架构、网关、Redis、Sa-Token等专业概念翻译成小学生也能听懂的大白话,从0开始系统化讲解如何搭建一套完整的微服务全局权限校验系统。文档包含完整的架构设计、核心流程、可直接复制的代码样例,以及生产环境最佳实践(含非最佳实践对比案例),新手也能10分钟上手,快速跑通全流程。


目录

  1. [先把所有专业词「翻译」成校园大白话](#1-先把所有专业词翻译进校园大白话)

  2. [整个系统的完整工作流程](#2-整个系统的完整工作流程)

  3. [核心架构与组件分工](#3-核心架构与组件分工)

  4. [从零开始代码实现](#4-从零开始代码实现)

  5. [核心原理解析](#5-核心原理解析)

  6. [登出请求的特殊处理](#6-登出请求的特殊处理)

  7. [生产环境最佳实践(含非最佳实践对比)](#7-生产环境最佳实践含非最佳实践对比)

  8. [最终总结](#8-最终总结)


1. 先把所有专业词「翻译」成校园大白话

在讲架构之前,我们先把所有组件对应到你熟悉的校园场景,彻底搞懂每个东西是干嘛的、为什么需要它。

专业术语

校园大白话类比

核心作用(一句话)

微服务集群

学校里的独立小楼:<br>教务处(用户服务)、图书馆(内容服务)、食堂(订单服务)<br>每个楼只干一件事,互不干扰

把大系统拆成多个小服务,坏了一个不影响全校,扩容、维护更方便

网关(Spring Cloud Gateway)

学校的大门+总保安室+指路员

所有外来人员(用户请求)必须先过这里,一次查完身份、权限,没问题再给你指路(路由转发)到对应的楼

Redis

学校的总档案室

统一存放全校的学生花名册、身份卡信息、权限表,保证全学校看到的内容完全一致

Sa-Token

全校的统一门禁管理系统

管着「发卡(登录)、验卡(校验登录)、查权限、踢人、封禁」全流程

1.1 为什么要把权限校验放在「大门」?

如果每个楼都单独设保安:

  • 重复干活,标准不统一,改规则麻烦

  • 坏人可以绕开大门,从楼后面的窗户翻进去

把所有检查放在大门总保安室:

  • 一次检查,全学校通用

  • 规则统一,改一次全学校生效

  • 所有人必须先过大门,绝对安全

1.2 为什么必须要有「总档案室(Redis)」?

如果没有总档案室,每个保安都自己揣一本花名册:

  • 学生被封禁了,大门保安知道了,图书馆保安不知道

  • 多个保安的花名册不一样,全学校乱套了

有了总档案室:

  • 所有保安都来同一个地方查信息,全学校内容100%一致

  • 改信息只需要改一次,所有人马上就能查到最新的

  • 速度极快,1毫秒就能出结果


2. 整个系统的完整工作流程

我们用「学生要去图书馆查书」的例子,把整个请求流程讲清楚。

2.1 校园场景版流程

  1. 学生到大门:拿着学生卡(Token),要去图书馆(内容服务)查书

  2. 总保安第一步检查:看是不是白名单(比如教务处),不是的话必须查卡

  3. 总保安第二步查身份:拿着学生卡去总档案室(Redis)核对:卡是不是真的?有没有过期?

  4. 总保安第三步查权限:查权限表:这个学生有没有进图书馆的权限?

  5. 总保安指路放行:身份、权限都没问题,给学生指路(路由转发)到图书馆

  6. 图书馆细粒度检查:不用再查卡,但可以做更细的检查:能不能借这本专业书?

  7. 提供服务:检查通过,提供查书、借书的服务

2.2 对应专业版HTTP请求流程

  1. 前端发起请求,携带Token,先到达网关

  2. 网关判断:请求路径是否在白名单里?在的话直接放行

  3. 不在白名单:调用Sa-Token,校验Token是否有效(从Redis读取会话)

  4. 登录校验通过:校验用户是否有访问该接口的粗粒度权限

  5. 权限校验通过:网关按照配置的路由规则,把请求转发到对应的后端微服务

  6. 后端微服务收到请求:Sa-Token自动从Redis同步会话,执行细粒度权限校验

  7. 校验通过:执行业务逻辑,返回结果


3. 核心架构与组件分工

我们用最主流的 Spring Cloud Alibaba + Spring Cloud Gateway + Redis + Sa-Token 架构。

模块名称

核心职责

对应校园角色

公共依赖模块(common)

存放通用配置、依赖、工具类

学校的统一规章制度

网关模块(gateway)

全局请求拦截、登录校验、粗粒度权限校验、路由转发

学校大门+总保安室+指路员

业务微服务模块

处理具体业务逻辑,执行细粒度权限校验

学校里的各个小楼

Redis服务

分布式会话存储、权限数据缓存

学校总档案室

3.1 核心设计原则

  1. 网关做全局通用校验:所有请求必须过网关,统一做登录校验

  2. 业务服务做细粒度校验:双重保险,网关只保证你能进这个楼

  3. 全集群会话共享:所有模块都连接同一个Redis

  4. 配置统一管理:通用配置都放在公共模块


4. 从零开始代码实现

前置环境准备

  • JDK 17+

  • Maven 3.6+

  • Redis 6.0+

  • Spring Cloud Alibaba 2023.x


4.1 步骤1:创建公共依赖模块(common)

4.1.1 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">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>common</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>3.2.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>2023.0.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>2023.0.1.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>1.38.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

4.1.2 实现Sa-Token的权限查询接口

package com.example.common.satoken;

import cn.dev33.satoken.stp.StpInterface;
import org.springframework.stereotype.Component;

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

@Component
public class StpInterfaceImpl implements StpInterface {

    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        List<String> permissionList = new ArrayList<>();
        
        if ("10001".equals(loginId.toString())) {
            permissionList.add("*"); // 管理员拥有所有权限
            return permissionList;
        }
        
        if ("10002".equals(loginId.toString())) {
            permissionList.add("content:view");
            permissionList.add("user:info");
        }
        
        return permissionList;
    }

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        List<String> roleList = new ArrayList<>();
        
        if ("10001".equals(loginId.toString())) {
            roleList.add("admin");
        }
        if ("10002".equals(loginId.toString())) {
            roleList.add("user");
        }
        
        return roleList;
    }
}

4.1.3 统一异常处理类

package com.example.common.exception;

import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import lombok.Data;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NotLoginException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public Result handleNotLoginException(NotLoginException e) {
        return Result.error(401, "未登录,请先登录");
    }

    @ExceptionHandler(NotPermissionException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public Result handleNotPermissionException(NotPermissionException e) {
        return Result.error(403, "权限不足,无法访问");
    }

    @Data
    public static class Result {
        private int code;
        private String msg;
        private Object data;

        public static Result success(Object data) {
            Result result = new Result();
            result.setCode(200);
            result.setMsg("操作成功");
            result.setData(data);
            return result;
        }

        public static Result error(int code, String msg) {
            Result result = new Result();
            result.setCode(code);
            result.setMsg(msg);
            return result;
        }
    }
}

4.2 步骤2:创建网关模块(gateway)

4.2.1 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">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>gateway</artifactId>
    <version>1.0.0</version>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
            <version>1.38.0</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
    </dependencies>
</project>

4.2.2 编写网关全局拦截器

package com.example.gateway.filter;

import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SaTokenGatewayFilter {

    // 白名单:不用登录就能访问的接口
    private static final String[] WHITE_LIST = {
            "/user/login",
            "/user/register",
            "/user/captcha"
    };

    @Bean
    public SaReactorFilter saReactorFilter() {
        return new SaReactorFilter()
                .addInclude("/**")
                .addExclude(WHITE_LIST)
                .setAuth(obj -> {
                    // 1. 先校验是否登录
                    StpUtil.checkLogin();

                    // 2. 粗粒度权限校验
                    SaRouter.match("/content/**", () -> StpUtil.checkPermission("content:*"));
                    SaRouter.match("/user/**", r -> !r.isMatch(WHITE_LIST), () -> StpUtil.checkPermission("user:*"));
                })
                .setError(e -> SaResult.error(e.getMessage()));
    }
}

4.2.3 编写网关配置文件 application.yml

server:
  port: 8080

spring:
  application:
    name: gateway
  data:
    redis:
      host: localhost
      port: 6379
      password: 
      database: 0
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/user/**
        - id: content-service
          uri: lb://content-service
          predicates:
            - Path=/content/**
    nacos:
      discovery:
        server-addr: localhost:8848
        namespace: public

sa-token:
  token-name: satoken
  token-timeout: 2592000
  is-concurrent: true
  is-share: true
  token-style: uuid
  is-log: false

4.3 步骤3:创建业务微服务(user-service)

4.3.1 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">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>user-service</artifactId>
    <version>1.0.0</version>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot3-starter</artifactId>
            <version>1.38.0</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
    </dependencies>
</project>

4.3.2 编写登录接口

package com.example.user.controller;

import cn.dev33.satoken.stp.StpUtil;
import com.example.common.exception.GlobalExceptionHandler;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
public class UserController {

    @PostMapping("/login")
    public GlobalExceptionHandler.Result login(@RequestParam String username, @RequestParam String password) {
        if ("admin".equals(username) && "123456".equals(password)) {
            StpUtil.login(10001);
            return GlobalExceptionHandler.Result.success(StpUtil.getTokenInfo());
        }
        if ("user".equals(username) && "123456".equals(password)) {
            StpUtil.login(10002);
            return GlobalExceptionHandler.Result.success(StpUtil.getTokenInfo());
        }
        return GlobalExceptionHandler.Result.error(400, "账号密码错误");
    }

    @GetMapping("/info")
    public GlobalExceptionHandler.Result getUserInfo() {
        Long userId = StpUtil.getLoginIdAsLong();
        return GlobalExceptionHandler.Result.success("当前登录用户ID:" + userId);
    }

    @PostMapping("/logout")
    public GlobalExceptionHandler.Result logout() {
        StpUtil.logout();
        return GlobalExceptionHandler.Result.success("退出登录成功");
    }
}

4.3.3 配置文件 application.yml

server:
  port: 8081

spring:
  application:
    name: user-service
  data:
    redis:
      host: localhost
      port: 6379
      password: 
      database: 0
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        namespace: public

sa-token:
  token-name: satoken
  token-timeout: 2592000
  is-concurrent: true
  is-share: true
  token-style: uuid

4.4 步骤4:创建第二个业务微服务(content-service)

4.4.1 编写内容服务接口

package com.example.content.controller;

import cn.dev33.satoken.annotation.SaCheckPermission;
import com.example.common.exception.GlobalExceptionHandler;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/content")
public class ContentController {

    @GetMapping("/list")
    @SaCheckPermission("content:view")
    public GlobalExceptionHandler.Result getContentList() {
        return GlobalExceptionHandler.Result.success("这是内容列表,只有有content:view权限的人能看到");
    }

    @PostMapping("/edit")
    @SaCheckPermission("content:edit")
    public GlobalExceptionHandler.Result editContent() {
        return GlobalExceptionHandler.Result.success("内容编辑成功,只有有content:edit权限的人能操作");
    }
}

5. 核心原理解析

5.1 分布式会话共享原理

  • 用户登录时,Sa-Token生成唯一Token,在Redis里创建对应的Session对象

  • 网关和所有微服务都连接同一个Redis,都能通过Token拿到完全一致的会话信息

  • 修改用户权限、封禁用户,只需要修改Redis里的Session,全集群马上生效

5.2 网关拦截的核心原理

Spring Cloud Gateway的过滤器机制,会把所有请求按顺序经过我们配置的SaReactorFilter

  • 先判断是否在白名单,在的话直接跳过

  • 不在白名单的话,先校验Token是否有效

  • 登录校验通过后,执行粗粒度的权限校验

  • 所有校验都通过后,转发到对应的后端微服务

5.3 双重权限校验的安全原理

  • 网关层的粗粒度校验:把所有坏人拦在外面

  • 业务服务层的细粒度校验:双重保险,即使网关出现配置漏洞,业务服务也能做二次校验


6. 登出请求的特殊处理

6.1 先纠正2个致命安全误区

  1. 绝对不能信任用户请求里自带的id参数:用户可以随意修改,直接使用会导致严重的越权漏洞

  2. 登出接口绝对不能放进白名单:注销学生卡必须先出示有效学生卡

6.2 登出请求的完整正确流程

  1. 前端发起登出请求,只需要在请求头里带上token

  2. 网关校验token有效性,从Redis解析出真实的loginId

  3. 下游服务收到请求,自动从token里解析loginId,调用Sa-Token的logout方法

  4. 删除Redis里对应的会话,让token彻底失效

6.3 极简零改造方案(99%业务场景首选)

下游user-service的登出接口,一行代码搞定:

@PostMapping("/logout")
public GlobalExceptionHandler.Result logout() {
    StpUtil.logout();
    return GlobalExceptionHandler.Result.success("退出登录成功");
}

7. 生产环境最佳实践(含非最佳实践对比)

7.1 白名单配置

最佳实践

非最佳实践(反面教材)

为什么最佳实践更好

白名单要精准,只放行必须不用登录的接口(登录、注册、验证码、健康检查)

为了省事,把整个服务都放进白名单,比如/user/**

非最佳实践会导致所有用户接口都不用登录,任何人都能访问,严重的安全漏洞

白名单要做环境隔离,开发环境可以多放一些,生产环境必须严格控制

开发、测试、生产环境用同一份白名单配置

非最佳实践会导致生产环境出现不必要的白名单接口,增加安全风险

白名单要定期审计,把不需要的接口及时删掉

白名单只增不减,永远不清理

非最佳实践会导致白名单越来越大,管理混乱,容易出现安全漏洞

7.2 权限分层

最佳实践

非最佳实践(反面教材)

为什么最佳实践更好

网关只做粗粒度校验(登录校验、服务级别的权限校验)

把所有细粒度的权限都放在网关,比如/content/edit必须有content:edit权限

非最佳实践会导致网关太重,维护成本极高,每次加新接口都要改网关配置

细粒度校验放在业务服务,用@SaCheckPermission注解

业务服务不做权限校验,完全依赖网关

非最佳实践会导致如果网关出现配置漏洞,业务服务完全没有防护,严重的安全风险

权限设计要最小化,给用户分配刚好够用的权限

为了省事,给所有用户都分配管理员权限

非最佳实践会导致任何用户都能做任何操作,完全没有权限控制,严重的安全漏洞

7.3 Redis使用

最佳实践

非最佳实践(反面教材)

为什么最佳实践更好

必须用集群模式(主从+哨兵/Redis Cluster)

生产环境用单机Redis

非最佳实践会导致Redis单点故障,整个系统的鉴权功能完全不可用

会话数据要设置过期时间,不要永久存储

所有会话数据永久存储,不设置过期时间

非最佳实践会导致Redis的内存越来越满,性能下降,最终内存溢出

序列化要用Jackson

用JDK序列化

非最佳实践会导致序列化后的体积大、性能差、兼容性差,升级JDK版本可能会出问题

Redis要做监控告警(内存使用率、QPS、响应时间)

不做任何监控,出了问题才知道

非最佳实践会导致问题不能及时发现,影响业务正常运行

7.4 安全防护

最佳实践

非最佳实践(反面教材)

为什么最佳实践更好

Token要放在请求头里,不要放在URL参数里

把Token放在URL参数里,比如/user/info?token=xxx

非最佳实践会导致Token被浏览器记录、被服务器日志记录,容易泄露

必须用HTTPS传输

生产环境用HTTP传输

非最佳实践会导致Token被抓包、窃取,任何人都能冒充用户登录

限制账号并发登录,同一账号最多同时登录的设备数

不限制并发登录,同一账号可以在无限多设备上登录

非最佳实践会导致账号被滥用,一个账号卖给无数人用

登出必须彻底删除Redis里的会话

只在前端删除token,后端不做任何处理

非最佳实践会导致token还是有效,坏人拿到token还是能登录,严重的安全风险

7.5 高可用

最佳实践

非最佳实践(反面教材)

为什么最佳实践更好

网关要集群部署,前面加负载均衡

生产环境只部署一个网关节点

非最佳实践会导致网关节点故障,整个系统完全不可用

在网关层配置熔断降级,当某个后端服务不可用时,快速失败

不做熔断降级,后端服务不可用时,网关一直等待

非最佳实践会导致网关的线程池被占满,所有请求都超时,整个系统雪崩

在网关层配置限流,防止恶意请求、DDOS攻击

不做任何限流,来多少请求接多少

非最佳实践会导致后端服务被恶意请求打垮,正常用户无法访问

接入链路追踪(SkyWalking/Pinpoint),监控请求的全流程

不做任何监控,出了问题不知道在哪

非最佳实践会导致问题排查困难,定位问题需要很长时间


8. 最终总结

Sa-Token配合网关+Redis的微服务权限架构,核心就是**「统一入口、一次校验、全集群共享、双重保险」**:

  1. 网关作为统一入口,所有请求必须经过,做全局的登录校验和粗粒度权限校验

  2. Redis作为统一的会话存储,保证全集群所有模块的会话、权限数据100%一致

  3. 业务服务做细粒度的权限校验,和网关形成双重保险,绝对安全

  4. Sa-Token屏蔽了所有底层的复杂逻辑,用极简的API实现了全场景的权限管理

一句话记住:网关管大门,Redis管档案,Sa-Token管门禁,业务服务管自己的事,分工明确,安全可靠,开箱即用。

两块二每分钟