11、发布系统-用户认证与权限体系

11.1 认证架构概览

发布系统面向内部运维和开发人员,不接入外部 SSO(单点登录),采用基于 Servlet Session 的本地认证 + ThreadLocal 用户上下文注入的轻量方案。整体认证链路如下:

复制代码
浏览器请求
  │
  ▼
AuthFilter (Filter, order=1)
  ├── 公开路径? → 直接放行(静态资源、/login、/webhook)
  ├── Session 有 currentUser? → CurrentUserHolder.set() → 放行
  └── 未登录 → 302 重定向 /login
  │
  ▼
Controller 处理请求
  │
  ▼
业务层通过 UserInfoAdapter 读取用户信息
  │
  ▼
finally: CurrentUserHolder.clear()

核心组件只有四个类:

组件 路径 职责
AuthFilter admin/config/ 登录拦截,Session 校验,用户注入
CurrentUserHolder business/util/ ThreadLocal 存储当前请求的用户上下文
UserInfoAdapter business/util/ 业务层读取用户信息的适配器
InnerUtil.isPermitted() business/util/ 权限判断入口

两个用户实体分别对应两种认证场景:

实体 场景
PublishAccount publish_account 本地登录(用户名 + BCrypt 密码)
PublishUser publish_user GitLab Token 绑定(调用 GitLab API)

11.2 AuthFilter --- 登录拦截器

AuthFilter 是认证体系的核心,实现了 jakarta.servlet.Filter 接口,通过内部静态配置类 Config 中的 FilterRegistrationBean 注册到 Servlet 容器,拦截所有路径(/*),优先级为 order=1

11.2.1 公开路径定义

两类路径无需登录即可访问:

java 复制代码
private static final Set<String> PUBLIC_PREFIXES = Set.of(
        "/css/", "/img/", "/images/", "/js/", "/favicon.ico",
        "/v3/api-docs", "/swagger-ui",
        "/statistic/"
);

private static final Set<String> PUBLIC_PATHS = Set.of(
        "/login", "/register", "/logout", "/webhook"
);

PUBLIC_PREFIXES 使用前缀匹配,覆盖静态资源(CSS、图片、JS)、Swagger 文档、以及统计页面。PUBLIC_PATHS 使用精确匹配,覆盖登录、注册、登出三个用户操作路径,以及 GitLab Webhook 回调路径。

需要特别说明 /webhook 的设计:它被放在公开路径中,因为 GitLab 在推送 Webhook 事件时不会携带 Session Cookie。Webhook 的安全性由 GitLab Secret Token 在业务层单独校验,不依赖 Session 机制。

isPublic() 方法同时检查精确路径和前缀:

java 复制代码
private boolean isPublic(String path) {
    if (PUBLIC_PATHS.contains(path)) return true;
    for (String prefix : PUBLIC_PREFIXES) {
        if (path.startsWith(prefix)) return true;
    }
    return false;
}

11.2.2 doFilter() 核心流程

java 复制代码
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) resp;
    String path = request.getRequestURI();

    // 第一分支:公开路径直接放行(但仍尝试注入用户)
    if (isPublic(path)) {
        injectUser(request);
        chain.doFilter(req, resp);
        CurrentUserHolder.clear();
        return;
    }

    // 第二分支:检查 Session 中的 currentUser
    Object user = request.getSession().getAttribute(SESSION_USER_KEY);
    if (user instanceof PublishAccount account) {
        CurrentUserHolder.set(account.getUsername(),
                account.getDisplayName() != null ? account.getDisplayName() : account.getUsername());
        chain.doFilter(req, resp);
        CurrentUserHolder.clear();
        return;
    }

    // 第三分支:未登录 → 重定向到登录页
    response.sendRedirect(request.getContextPath() + "/login");
}

三个分支的逻辑非常清晰:

  1. 公开路径分支 :不强制要求登录,但会尝试从 Session 中获取用户信息并注入到 CurrentUserHolder。这样在登录页也可以判断用户是否已经登录,避免重复登录。
  2. 已登录分支 :从 Session 中取出 PublishAccount 对象,将用户名和显示名注入 ThreadLocal,放行请求。finally 块中的 clear() 确保请求结束后清理。
  3. 未登录分支 :直接 302 重定向到 /login

11.2.3 injectUser() 辅助方法

公开路径上虽然不强制登录,但 injectUser() 仍会尝试注入用户信息:

java 复制代码
private void injectUser(HttpServletRequest request) {
    Object user = request.getSession().getAttribute(SESSION_USER_KEY);
    if (user instanceof PublishAccount account) {
        CurrentUserHolder.set(account.getUsername(),
                account.getDisplayName() != null ? account.getDisplayName() : account.getUsername());
    }
}

这使登录页能够判断用户当前是否已登录:如果 CurrentUserHolder.get() != null,说明 Session 中仍有有效会话,可以直接跳转到主页,不必再展示登录表单。

11.2.4 Filter 注册

AuthFilter 的注册通过内部静态类 Config 完成:

java 复制代码
@Configuration
public static class Config {
    @Bean
    public FilterRegistrationBean<AuthFilter> authFilterRegistration() {
        FilterRegistrationBean<AuthFilter> reg = new FilterRegistrationBean<>();
        reg.setFilter(new AuthFilter());
        reg.addUrlPatterns("/*");
        reg.setName("authFilter");
        reg.setOrder(1);
        return reg;
    }
}

这种将 Filter 和它的注册配置放在同一个类中的组织方式,减少了配置类数量,让认证逻辑集中在一个文件中。order=1 确保 AuthFilter 在 Filter 链中最先执行。

11.3 CurrentUserHolder --- ThreadLocal 用户上下文

CurrentUserHolder 是一个典型的 ThreadLocal 工具类,为每个请求线程存储独立的用户信息:

java 复制代码
public final class CurrentUserHolder {

    private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>();

    private CurrentUserHolder() {}

    public static void set(String username, String displayName) {
        HOLDER.set(new UserInfo(username, displayName));
    }

    public static UserInfo get() {
        return HOLDER.get();
    }

    public static void clear() {
        HOLDER.remove();
    }

    public static class UserInfo {
        private final String username;
        private final String displayName;

        UserInfo(String username, String displayName) {
            this.username = username;
            this.displayName = displayName;
        }

        public String getUsername() { return username; }
        public String getDisplayName() { return displayName; }
    }
}

几个设计要点:

  1. 不可变内部类 UserInfo :字段为 final,构造函数为包级私有(UserInfo 无 public 修饰符),外部只能通过 get() 读取。这避免了业务代码意外修改用户信息。
  2. clear() 使用 remove() 而非 set(null)ThreadLocal.remove() 会清除当前线程 Entry 中对 value 的引用,防止 Tomcat 线程池复用导致的内存泄漏数据串扰
  3. 构造函数私有:工具类禁止实例化。

AuthFilter 中,每个请求的 finally 块都调用 CurrentUserHolder.clear()。即使 Filter 链中某个环节抛出异常,clear() 依然会被执行------这就是 doFilter() 中每个放行分支都紧跟 clear() 的原因。

11.4 UserInfoAdapter --- 业务层用户读取

业务代码不应直接依赖 CurrentUserHolder(它是一个底层工具类),而是通过 UserInfoAdapter 这个 Spring 组件来读取:

java 复制代码
@Slf4j
@Component
public class UserInfoAdapter {

    public String getCurrentUserName() {
        CurrentUserHolder.UserInfo user = CurrentUserHolder.get();
        return user != null ? user.getUsername() : "anonymous";
    }

    public DefaultUser getCurrentUser() {
        CurrentUserHolder.UserInfo user = CurrentUserHolder.get();
        if (user == null) {
            return new DefaultUser("anonymous");
        }
        String name = user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
        return new DefaultUser(name);
    }

    public static class DefaultUser {
        private final String displayName;
        public DefaultUser(String displayName) { this.displayName = displayName; }
        public String getDisplayName() { return displayName; }
    }
}

适配器提供的两个方法各有用途:

  • getCurrentUserName():返回用户名,用于日志记录、操作审计等需要记录"谁做了这个操作"的场景。
  • getCurrentUser():返回 DefaultUser 对象,用于前端页面展示"欢迎,张三"。

当 ThreadLocal 中没有用户信息时(理论上不应发生,但作为防御性编程)返回 "anonymous",避免 NPE。

11.5 PublishAccount --- 本地登录

PublishAccount 是本地用户名密码认证的实体,对应 publish_account 表:

java 复制代码
@TableName("publish_account")
public class PublishAccount extends BaseEntity {
    private String username;      // 登录用户名
    private String password;      // BCrypt 加密密码
    private String displayName;   // 显示名称
    private String role;          // 角色:admin / user
    private boolean enabled;      // 是否启用
    private Date createTime;      // 创建时间
}

数据库建表语句在 Flyway 迁移脚本 V2__add_account.sql 中:

sql 复制代码
CREATE TABLE IF NOT EXISTS `publish_account` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `username`      VARCHAR(100)     NOT NULL              COMMENT '登录用户名',
  `password`      VARCHAR(256)     NOT NULL              COMMENT 'BCrypt 加密密码',
  `display_name`  VARCHAR(100)     DEFAULT NULL          COMMENT '显示名称',
  `role`          VARCHAR(50)      DEFAULT 'user'        COMMENT '角色: admin/user',
  `enabled`       TINYINT          DEFAULT 1             COMMENT '是否启用',
  `create_time`   TIMESTAMP        NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录账号';

username 字段上有唯一索引,保证用户名不重复。role 字段目前支持 adminuser 两种值。enabled 字段为 TINYINT,可以软禁用账号而不删除记录。

默认管理员账号通过 INSERT IGNORE 写入,只会在首次 Flyway 迁移时插入:

sql 复制代码
INSERT IGNORE INTO `publish_account` (`username`, `password`, `display_name`, `role`)
VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '管理员', 'admin');

默认密码 admin123 已经过 BCrypt 加密存储。PublishAccountMapper 提供了按用户名查询的方法:

java 复制代码
@Select("SELECT * FROM publish_account WHERE username = #{username}")
PublishAccount findByUsername(@Param("username") String username);

11.6 PublishUser --- GitLab Token 绑定

PublishUser 是另一个用户实体,用于绑定开发者的 GitLab 账号和 Private Token:

java 复制代码
@TableName("publish_user")
public class PublishUser extends BaseEntity {
    private String name;          // GitLab 用户名
    private String email;         // 邮箱
    private String displayName;   // 显示名称
    private String token;         // GitLab Private Token
    private String role;          // 角色
    private Date createTime;      // 创建时间
}

PublishUserPublishAccount 的区别在于认证来源:

特性 PublishAccount PublishUser
认证方式 用户名 + BCrypt 密码 GitLab Private Token
用途 登录发布系统 Web 界面 调用 GitLab API(clone、查看提交等)
publish_account publish_user
Session 存储 是(currentUser

PublishUsertoken 字段存储的是开发者在 GitLab 上生成的 Personal Access Token。发布系统在执行 Git 操作(如 clone 仓库、查看分支列表)时,会使用用户自己的 Token 而非系统统一 Token 调用 GitLab API。这样 GitLab 的审计日志能精确追踪每次 API 调用是由哪个开发者发起的。

PublishUserMapper 同样提供按名称查询:

java 复制代码
@Select("SELECT * FROM publish_user WHERE name = #{name}")
List<PublishUser> findByName(@Param("name") String name);

11.7 权限控制

发布系统的权限模型目前采用简化设计,为未来的精细化权限控制预留了扩展点。

11.7.1 InnerUtil 中的权限判断

InnerUtil 提供了两个权限判断方法:

java 复制代码
public static boolean isPermitted(String role) {
    return true;
}

public static boolean isPermittedOnline() {
    return true;
}

当前两个方法都直接返回 true,这是有意的简化。在一个小型内部工具中,过度设计 RBAC(基于角色的访问控制)往往得不偿失。与其花时间实现一个复杂的权限矩阵,不如先把核心流程跑通,待需要时再按实际需求扩展。

11.7.2 时间限制

发布系统有一个业务层面的时间限制开关 isTimeRestrict:在非工作时间禁止执行发布操作,降低因疲劳或人手不足导致的误操作风险。这个限制可以被 x-man 角色绕过------超级管理员不受时间窗口约束,以应对紧急发布场景。

11.7.3 角色体系

当前角色定义非常简单:

角色 说明
admin 管理员,可管理用户、配置业务
user 普通用户,可触发发布
x-man 超级管理员,绕过所有限制

角色信息存储在 PublishAccount.role 字段中,AuthFilter 登录后将完整的 PublishAccount 对象放入 Session,业务层通过 UserInfoAdapter 读取后自行判断权限。

11.8 安全设计要点

密码存储

密码使用 BCrypt 加密存储,建表语句中的默认管理员密码 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy 就是 BCrypt 的密文。$2a$10$ 前缀表示使用 BCrypt 算法,cost factor 为 10。在验证登录时,应用层使用 BCrypt 的 matches(rawPassword, encodedPassword) 方法比对,原始密码不会出现在任何日志或数据库中。

Session 管理

Session 完全托管给 Servlet 容器(内嵌 Tomcat),不引入 Redis 等外部 Session 存储。对于发布系统这种单实例部署、并发量低的内部工具,Servlet 容器的内存 Session 完全够用,不需要引入分布式 Session 的复杂性。

Webhook 安全

/webhook 路径位于 PUBLIC_PATHS 中,不要求 Session 认证。但 Webhook 的安全性由 GitLab 的 Secret Token 机制保障:GitLab 在发送 Webhook 事件时会在 HTTP Header 中携带 X-Gitlab-Token,业务层在处理 Webhook 时校验该 Token 与配置的 Secret 是否一致。这相当于在 Filter 层"放行"、在业务层"二次认证"的分层防护策略。

CORS 支持

CorsFilter 允许所有来源的跨域请求:

java 复制代码
public class CorsFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletResponse resp = (HttpServletResponse) response;
        resp.setHeader("Access-Control-Allow-Origin", "*");
        resp.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        resp.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
        chain.doFilter(request, response);
    }
}

当发布系统需要前后端分离部署,或前端开发时使用独立端口时,CorsFilter 确保跨域请求不会被浏览器拦截。在生产环境中,如果需要更严格的控制,可以将 * 改为具体的允许域名。

CorsFilter 在 FilterConfig 中注册,优先级 order=2,排在 AuthFilter(order=1)之后:

java 复制代码
@Bean
public FilterRegistrationBean<CorsFilter> corsFilter() {
    FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>();
    bean.setFilter(new CorsFilter());
    bean.addUrlPatterns("/*");
    bean.setOrder(2);
    return bean;
}

编码过滤器

FilterConfig 中还注册了一个 CharacterEncodingFilter,设置 order=1(因为 AuthFilter 自身在内部类 Config 中注册为 order=1,两者通过 FilterRegistrationBean 的不同注册方式区分了上下文):

java 复制代码
@Bean
public FilterRegistrationBean<CharacterEncodingFilter> encodingFilter() {
    FilterRegistrationBean<CharacterEncodingFilter> bean = new FilterRegistrationBean<>();
    bean.setFilter(new CharacterEncodingFilter("UTF-8", true));
    bean.addUrlPatterns("/*");
    bean.setOrder(1);
    return bean;
}

CharacterEncodingFilter 的第二个参数 true 表示强制设置请求和响应的编码为 UTF-8,防止中文乱码。

11.9 Filter 链执行顺序

综合 FilterConfigAuthFilter.Config 的注册,完整的 Filter 链及其顺序如下:

复制代码
请求进入
  │
  ▼
1. AuthFilter (order=1, 通过内部 Config 注册)
  ├── 公开路径 → 放行
  ├── 已登录 → CurrentUserHolder.set() → 放行
  └── 未登录 → 302 /login
  │
  ▼
2. CorsFilter (order=2, FilterConfig 注册)
  └── 设置 CORS 响应头
  │
  ▼
Controller
  │
  ▼
finally: CurrentUserHolder.clear()

这个顺序安排是合理的:AuthFilter 需要最先执行,在请求到达 CorsFilter 和 Controller 之前就完成认证判断;如果未登录,直接重定向,后续 Filter 和 Controller 都不会被调用。

11.10 小结

本章拆解了发布系统的认证与权限体系,核心要点如下:

  1. AuthFilter 是认证的唯一入口,通过 Servlet Filter 拦截所有请求,区分公开路径和受保护路径,基于 Session 判断登录状态。
  2. CurrentUserHolder + UserInfoAdapter 构成用户上下文注入与读取的两层抽象:底层 ThreadLocal 存储,上层适配器暴露业务友好的 API。
  3. PublishAccountPublishUser 分别对应"系统登录"和"GitLab API 调用"两种认证场景,前者通过 BCrypt 密码验证,后者通过 Personal Access Token。
  4. 权限控制采用简化设计 ,当前全量放行,但预留了 isPermitted() 和角色字段等扩展点,满足小型内部工具"够用就好"的原则。
  5. 安全方面,密码 BCrypt 加密、Session 容器托管、Webhook 分层认证、CORS 和编码过滤器的组合,构成了一套完整但不过度设计的防护方案。
相关推荐
吴声子夜歌4 小时前
Java面试——算法
java·算法·面试
evans在进步4 小时前
LeetCode 64:最小路径和——Java 原地动态规划详解
java·leetcode·动态规划
凤山老林5 小时前
Spring Boot 3.x AOT 编译实战:从原理、踩坑到生产落地
java·spring boot·后端
博傅5 小时前
Spring 核心原理
java·后端·spring
yurenpai(27届找实习中)6 小时前
从零读懂 AI 智能客服(一):模块职责与 SSE 聊天链路(后端架构)
java·人工智能·架构·langchain4j
陈皮波比茶6 小时前
Swagger
java
玹外之音6 小时前
Spring AI + Elasticsearch 向量存储实战:从零构建智能文档检索系统
人工智能·spring·elasticsearch
小闫BI设源码6 小时前
Elasticsearch面试必看:如何让客户端精准选择节点高效执行请求?
java·elasticsearch·面试宝典·深入解析
摇滚侠7 小时前
《SpringBoot 3:入门与应用实战》第 5 章 使用 Spring Boot 阅读笔记 9
java·spring boot·笔记
进阶的小名7 小时前
Spring AI 2.0 探索:多 OpenAI-Compatible 模型接入,以及下一代 Session 记忆管理
java·人工智能·后端·gpt·spring·ai·chatgpt