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 和编码过滤器的组合,构成了一套完整但不过度设计的防护方案。
相关推荐
前端开发张小七1 小时前
Java 学习笔记 · 第二课:面向对象核心(封装、继承、多态)及接口与异常
java·后端·程序员
wangjialelele1 小时前
Selenium4 + Java Web自动化测试入门指南:从环境搭建到常用操作详解
java·开发语言·前端·测试工具·自动化
孙启超2 小时前
【AI应用开发】LangChain 中 Chain 和 Agent 核心区别?
java·人工智能·langchain·llm·rag·ai应用开发·agent loop
leoZ2312 小时前
实战复盘:用 Claude Code 从零搭一个 GitHub PR 统计工具
java·人工智能·python·深度学习·自然语言处理·github·llama
William Dawson2 小时前
【踩坑实录|Hive1\.2\.1数据服务接口5大疑难问题调试与全方位优化方案】
java·hive·spring boot
2601_955759882 小时前
如何识别 Claude API 低价值调用并优化
java
鹿角片ljp3 小时前
Java框架篇:Spring + SpringMVC + SpringBoot + MyBatis深度复习
java·开发语言
Lyra_Infra3 小时前
Java 应用启动脚本 JDK 路径适配优化文档
java·shell
ruleslol3 小时前
如何解决 Spring 中的循环依赖问题?
spring