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");
}
三个分支的逻辑非常清晰:
- 公开路径分支 :不强制要求登录,但会尝试从 Session 中获取用户信息并注入到
CurrentUserHolder。这样在登录页也可以判断用户是否已经登录,避免重复登录。 - 已登录分支 :从 Session 中取出
PublishAccount对象,将用户名和显示名注入 ThreadLocal,放行请求。finally 块中的clear()确保请求结束后清理。 - 未登录分支 :直接 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; }
}
}
几个设计要点:
- 不可变内部类
UserInfo:字段为final,构造函数为包级私有(UserInfo无 public 修饰符),外部只能通过get()读取。这避免了业务代码意外修改用户信息。 clear()使用remove()而非set(null):ThreadLocal.remove()会清除当前线程 Entry 中对 value 的引用,防止 Tomcat 线程池复用导致的内存泄漏 和数据串扰。- 构造函数私有:工具类禁止实例化。
在 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 字段目前支持 admin 和 user 两种值。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; // 创建时间
}
PublishUser 和 PublishAccount 的区别在于认证来源:
| 特性 | PublishAccount | PublishUser |
|---|---|---|
| 认证方式 | 用户名 + BCrypt 密码 | GitLab Private Token |
| 用途 | 登录发布系统 Web 界面 | 调用 GitLab API(clone、查看提交等) |
| 表 | publish_account |
publish_user |
| Session 存储 | 是(currentUser) |
否 |
PublishUser 的 token 字段存储的是开发者在 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 链执行顺序
综合 FilterConfig 和 AuthFilter.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 小结
本章拆解了发布系统的认证与权限体系,核心要点如下:
- AuthFilter 是认证的唯一入口,通过 Servlet Filter 拦截所有请求,区分公开路径和受保护路径,基于 Session 判断登录状态。
- CurrentUserHolder + UserInfoAdapter 构成用户上下文注入与读取的两层抽象:底层 ThreadLocal 存储,上层适配器暴露业务友好的 API。
- PublishAccount 和 PublishUser 分别对应"系统登录"和"GitLab API 调用"两种认证场景,前者通过 BCrypt 密码验证,后者通过 Personal Access Token。
- 权限控制采用简化设计 ,当前全量放行,但预留了
isPermitted()和角色字段等扩展点,满足小型内部工具"够用就好"的原则。 - 安全方面,密码 BCrypt 加密、Session 容器托管、Webhook 分层认证、CORS 和编码过滤器的组合,构成了一套完整但不过度设计的防护方案。