# lang-springweb **Repository Path**: net_yc60/lang-springweb ## Basic Information - **Project Name**: lang-springweb - **Description**: lang-springweb 是面向一体化单体(前后不分离)的 Spring Web 脚手架。后端内置 R 统一返回、全局异常处理、JWT 双令牌认证与用户管理,集群就绪;前端配套零依赖 api.js 与默认登录页,前后共享同一套错误码契约。新项目引入一个依赖即可开工。 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-04 - **Last Updated**: 2026-09-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # lang-springweb 前后一体的 Spring Web 脚手架:`R` 统一返回 + 全局异常 + JWT 双令牌认证(集群就绪)+ 零依赖前端 api.js,一体化单体项目一个依赖开箱即用。 *** ## 这是给谁用的 / 解决什么问题 一体化单体(前后不分离)项目都要反复重复三件事: 1. **后端**:手写统一返回体、全局异常处理、登录注册、token 校验、防暴力破解、用户表…… 2. **前端**:手写 fetch 封装、token 存取、401 跳登录、会话续期…… 3. **对齐**:前后端各自维护一套错误码,口径经常对不上。 `lang-springweb` 把这三件事一次性做完并**前后配套交付**:引入一个 starter 依赖,后端开箱即有统一返回 + 全局异常 + JWT 双令牌认证;前端用随包提供的 `api.js`,前后端共享同一份错误码契约,不再各写一套。 ## 设计哲学(为什么这样做) | 原则 | 含义 | | ------------- | ------------------------------------------------------------------- | | **单一正确路径** | 不提供一堆配置开关让使用者选择,由脚手架定死最优方案。"给别人选择只会提高出错概率",使用方无需也最好不要改。 | | **前后一体** | 前端 `api.js` 与后端 `R` 是一套配套交付,同一份契约、同一套错误码,语义严格一致。 | | **认证 / 授权分层** | 本期只做认证(你是谁);权限矩阵(用户 - 角色 - 权限点,线上可配置)是第二阶段独立子模块,避免把 "角色字段" 拍死在用户表上。 | | **集群就绪** | 从第一天起就是无状态、可水平扩展,不为单机便利牺牲未来集群能力。 | ## 核心能力 | 能力 | 说明 | | ---------------- | ------------------------------------------------------------------------------ | | **R\ 统一返回** | HTTP 恒 200,业务状态放 `body.code`;前后端共享同一份错误码,契约写死无开关 | | **全局异常处理** | 业务异常 / 参数校验 / 类型不匹配 / 请求体错误 / 兜底,统一转标准响应,不泄漏内部细节 | | **JWT 双令牌认证** | access = RS256 无状态 JWT(15min),refresh = 随机 256bit 存库哈希(7d,可撤销、刷新轮换);无内存会话,集群就绪 | | **防暴力破解** | 失败计数 + 锁定时间存 DB(集中式,集群一致),默认连续 5 次错误锁定 15 分钟 | | **零依赖前端 api.js** | 原生 fetch:自动带 Bearer、401 静默续期、登出清 token;默认登录页 `AuthLogin.init()` 一行接入 | | **可选安全扩展** | `lang-springweb-security-ext`:SSRF 私网拦截 + 路径穿越防护(纯 Java 核心零框架依赖) | | **双库兼容** | MySQL / SQLite 均支持,时间字段统一字符串映射,规避方言 / 驱动日期坑 | ## 工作原理 ### 1. R\:为什么 HTTP 恒为 200 `R` 把 "业务成败" 放在响应体 `code` 里,而不是依赖 HTTP 状态码: * **作用**:前端只按 `body.code` 分发逻辑,不判断 4xx/5xx;浏览器、网关、代理不会因为非 2xx 干扰请求链路;前后端错误处理是一套代码。 * **语义**:`{ "code": 200, "msg": "success", "data": {...} }`;失败时 `data=null`、`msg` 是人话提示。 * **错误码粒度**:只到 "前端能据以决策" 的粒度(如 40111 密码错误),不堆砌细分码;Java 枚举与前端常量同源,不一致不发生。 ### 2. JWT 双令牌:为什么是 "双" 而不是 "一" | 方案 | 问题 | | ------------------- | ---------------------------------------- | | 单一 long-lived token | 无法撤销:泄露后 7 天内一直有效,登出也无效 | | 服务端 session | 需共享存储(Redis/DB),集群复杂,且非前后分离场景耦合度高 | | **双令牌(本项目)** | 短命 access 减少泄露窗口;长命 refresh 存库哈希,可撤销、可轮换 | * **access(15min,RS256 无状态 JWT)**:payload 只有 `sub`(用户 ID)、`username`、`iat`、`exp`。任何节点拿到公钥即可验签,**无需共享 session**—— 这是集群就绪的核心。 * **refresh(7d,随机 256bit 不透明串)**:库中只存 SHA-256 哈希,不存明文。每次刷新**轮换**(旧 refresh 置撤销并发新),被盗后可单端撤销;登出也走撤销。 * **密钥**:RS256 密钥对(base64/PEM 配置注入);未配置时开发模式自动生成并打印提示,生产必须显式配置。未来独立认证服务 + JWKS 公钥端点时 JWT 结构不变。 ### 3. 防暴力破解:为什么计数放 DB 失败计数和锁定时间存在 `sys_user`(DB),而不是内存: * **作用**:多实例集群下行为一致 —— 用户在这台机器试错 5 次,换台机器也不会绕过锁定。 * **锁定优先**:锁定中即使密码正确也拒绝(40104);锁定到期自动解除并清计数。 * 密码始终用 BCrypt 存储与比对,数据库泄露也不还原明文。 ### 4. api.js:自动续期与 401 跳转 `api.js` 拦截所有请求,统一处理 token 生命周期: * 请求自动带 `Authorization: Bearer `; * 收到 401(过期)→ **静默**调用 `/api/auth/refresh` 换新双令牌 → **重放原请求**,用户无感知; * refresh 也失败(如已撤销 / 登出)→ 触发 `onUnauthorized`(默认跳登录页)。 ### 5. 双库兼容:为什么时间字段用字符串 MySQL 与 SQLite 的日期方言、JDBC 驱动行为差异大(SQLite 对 JDBC 日期类型的支持有坑): * 时间字段统一 `LocalDateTime` + 自定义转换器,**以 ISO 文本字符串落库**,规避方言差异; * 建表 DDL(MySQL / SQLite)分别维护,绝不手写 "通用" 原生 SQL; * 应用代码完全无感知,切库只改连接配置。 ### 6. 安全扩展(SSRF / 路径穿越) * **SSRF 私网拦截**:提交时把 URL 主机名解析为 IP,命中回环 / 私网 / 链路本地 / 组播 / IPv6 唯一本地任一即拒绝(fail-closed)—— 挡 "诱导服务器访问内网、云元数据端点 169.254.169.254" 这类攻击。 * **路径穿越防护**:把请求路径限制在允许根目录内,先 `normalize()` 消解 `..`,再按路径**组件**级比较(杜绝 `C:/data/app2` 被误判为在 `C:/data/app` 内)。 * 纯 JDK 实现(`lang-security-core` 零框架依赖),由 `security-ext` 包装为 Spring Bean。 ### 7. 一次请求的生命周期 ``` 浏览器 \ │ 发请求(api.js 自动带 Bearer) \ ▼ Spring Security 过滤链 \ │ JwtAuthenticationFilter:验签 access → 写入 SecurityContext(你是谁) \ │ 认证入口点:无凭证/无效 → 直接返回 40100 JSON(HTTP 恒 200) \ ▼ 控制器(@RestController) \ │ 业务逻辑,抛出 BizException 或直接 Response.ok(...) \ ▼ 全局异常处理(@RestControllerAdvice) \ │ 异常 → 统一转 {code, msg, data};兜底 Exception → 50000 不泄漏细节 \ ▼ 浏览器(api.js 按 body.code 分发) \ │ 200 → then;401 → 静默续期重放;其他 → catch(code, msg) ``` ## 认证流程(时序) ``` 浏览器 后端 (lang-springweb) \ │ POST /api/auth/login │ \ │ {username, password} │ \ │ ─────────────────────────────▶ │ 校验账号(BCrypt) + 防暴力计数 \ │ │ 签发 access(RS256 JWT) + refresh(存库哈希) \ │ ◀─────────────────────────────│ {code:200, data:{accessToken, refreshToken, user}} \ │ │ \ │ GET /api/xxx + Bearer access │ \ │ ─────────────────────────────▶ │ 过滤器验签 → 放行/401 \ │ │ \ │ access 过期 (40101/40100) │ \ │ api.js 自动用 refresh 换新 │ \ │ POST /api/auth/refresh │ 旧 refresh 置撤销 → 发新双令牌 \ │ │ \ │ POST /api/auth/logout │ 撤销 refresh(登出) ``` ## 快速开始(demo,SQLite 零安装) ``` \\# 构建(JDK 21+) mvn install \\# 运行 demo(启动自动建库建表 + 创建种子用户 admin / 123456) cd demo-app java -jar target/demo-app-1.0.0.jar \\# 打开:登录页 http://localhost:8080/login.html · 首页 http://localhost:8080/index.html ``` demo 首页内置三个演示:受保护接口(需登录)、SSRF 校验(试填 `http://169.254.169.254/...`)、路径穿越(试填 `../../etc/passwd`)。 ## 接入你自己的项目 ### 1. 引入依赖 ``` \\\ \ \\\com.lang\\\ \ \\\lang-springweb-starter\\\ \ \\\1.0.0\\\ \\\ \\\ \\\ \ \\\com.lang\\\ \ \\\lang-springweb-security-ext\\\ \ \\\1.0.0\\\ \\\ ``` > jpa 依赖为 optional:只用 > `R` > \+ 全局异常( > `auth.enabled=false` > )时无需数据源;开认证则必须配数据源,无内存兜底。 ### 2. 配置 ``` spring: \ datasource: # 认证开启必配(MySQL 或 SQLite) \ url: jdbc:mysql://localhost:3306/app?useSSL=false\\\&serverTimezone=Asia/Shanghai \ username: root \ password: xxx \ jpa: \ hibernate: \ ddl-auto: update # 自动建表;生产建议 validate + 手动 DDL lang: \ web: \ enabled: true # 总开关 \ auth: \ enabled: true # 认证开关 \ login-page: /login.html # 自定义登录页(可覆盖) \ max-fail: 5 # 连续失败锁定阈值 \ lock-minutes: 15 # 锁定分钟数 \ public-paths: # 额外公开路径(无需登录) \ - /health \ - /public/\\\*\\\* \ jwt: \ # RS256 密钥(base64 或 PEM)。不配则开发模式自动生成并打印提示,生产必须显式配置。 \ private-key: "" \ public-key: "" \ access-ttl: 15m # access 有效期 \ refresh-ttl: 7d # refresh 有效期 \ security: # 安全扩展(需引 security-ext) \ url: \ enabled: true # SSRF 私网拦截 \ path: \ enabled: true # 路径穿越防护 \ base-dir: ./data/downloads # 允许根目录 \ brand: # 登录页品牌信息(可配置,无需改 HTML) \ title: "登录" # 主标题 \ sub: "lang-springweb" # 副标题 \ theme: # 主题配置 \ mode: auto # auto(三态可选)/ light(固定亮色)/ dark(固定暗色) \ allow-switch: true # 是否显示切换按钮(mode=light/dark 时强制 false) ``` ### 3. 建表与种子用户 认证层自动建表(`ddl-auto: update`):`sys_user`、`sys_refresh_token`。密码用 BCrypt(`PasswordEncoder`)加密写入 `sys_user.password_hash`,例如 demo 的种子逻辑: ``` @Bean CommandLineRunner seed(SysUserRepository users, PasswordEncoder encoder) { \ return args -> users.findByUsername("admin").orElseGet(() -> { \ SysUser u = new SysUser(); \ u.setUsername("admin"); \ u.setPasswordHash(encoder.encode("123456")); \ u.setStatus(1); \ u.setCreatedAt(LocalDateTime.now()); \ u.setUpdatedAt(LocalDateTime.now()); \ return users.save(u); \ }); } ``` ### 4. 后端接口写法 ``` @RestController @RequestMapping("/api") public class HelloController { \ @GetMapping("/hello") \ public Response\\\> hello() { \ CurrentUser.AuthenticatedUser u = CurrentUser.get(); // 当前登录用户 \ return Response.ok(Map.of("message", "你好," + u.username())); \ } \ @PostMapping("/echo") \ public Response\\\> echo(@RequestBody Map\\\ body) { \ return Response.ok(body); // R\\\ 包装 \ } \ @GetMapping("/boom") \ public Response\\\ boom() { \ throw new BizException(ErrorCode.PARAM\\\_ERROR, "业务规则不满足"); // 全局异常接管 \ } } ``` ### 5. 前端接入 页面引用 `api.js`(静态资源已随 starter 打包): ``` \\\ \\\ ``` **登录页定制**(三种方式,建议第 2 种): 1. 直接用默认 `/login.html`(中性样式,功能完整); 2. 复制 `login.html` 改样式,页面保持 `form id="login-form"` + 字段 `name=username/password`,引用 `/js/web/api.js` + `/js/web/login.js`,`AuthLogin.init({formId:'login-form', redirect:'/index.html'})`; 3. 或用 `lang.web.auth.login-page` 指向你自己的登录页,手动调用 `Web.Auth.login()`。 ### 6. 明暗主题 内置三态主题系统:**light(亮色)/dark(暗色)/auto(跟随系统,默认)**。偏好记忆在 `localStorage['lang_web_theme']`,同域名所有页面共享,不依赖登录态。 **接入三步**(默认登录页已接入,业务页面按需): 1. `` 顶部、所有 CSS 之前内联防闪烁脚本(暗色用户刷新不白闪): ``` \ ``` 1. 引入主题 CSS + JS: ``` \ \ ``` 1. 页面颜色全部用 CSS 变量:`var(--bg-card)`、`var(--text-primary)`、`var(--accent)` 等。 **切换按钮(可选,由产品 / 开发决定是否开启)**:页面放容器并调用挂载即可,不需要按钮就不调用: ``` \
\
\ ``` 按钮三态循环:auto(🌓)→ light(☀️)→ dark(🌙)→ auto。auto 模式下不设 `data-theme`,由 CSS `@media (prefers-color-scheme: dark)` 原生跟随系统,系统主题变化时按钮图标实时更新。 **核心变量**(亮色 / 暗色):`--bg-primary`、`--bg-card`、`--bg-input`、`--bg-hover`、`--border-color`、`--text-primary`、`--text-secondary`、`--text-muted`、`--accent`、`--accent-text`、`--danger`、`--success`、`--radius`、`--shadow-card`。暗色配色参考 downloadHack(深蓝黑 `#1a1a2e` + 青色 `#00d4ff`),亮色保持简洁白底蓝按钮。 **API**:`Web.Theme.get()` / `set(mode)` / `toggle()` / `lock(mode)` / `applyConfig(config)` / `isDark()` / `isLocked()` / `mountBtn(id)`。 **固定主题(像 downloadHack 一样强制暗色,不允许用户修改)**:在 `application.yml` 配置: ``` lang: web: theme: mode: dark # dark=固定暗色 / light=固定亮色;此时 allow-switch 强制 false allow-switch: false ``` 配置后所有页面强制暗色、无切换按钮、用户无法修改;`Web.Theme.lock('dark')` 写入 localStorage,第二次访问起防闪烁零白闪。 **登录页品牌信息可配置**(无需改 HTML): ``` lang: web: brand: title: "我的管理后台" sub: "v2.0 · 内部系统" ``` 登录页加载时调用公开端点 `GET /api/auth/page-config`(无需登录)动态填充品牌和主题模式。 ## 模块结构 ``` lang-springweb/ ├── lang-security-core # 纯 Java 库(零框架):私网段判断、URL 安全校验、路径穿越防护 ├── lang-springweb-starter # 主 starter:R\\\ + 错误码 + 异常 + JWT 认证 + 前端 api.js/login.html ├── lang-springweb-security-ext # 可选安全扩展 starter:包装 core 为 Spring Bean(SSRF/路径防护) └── demo-app # 试验项目:落地验证载体(含端到端测试) ``` ## 认证端点 | 方法 | 路径 | 说明 | | ---- | ------------------- | -------------------------------- | | POST | `/api/auth/login` | 账号密码登录 → access + refresh + user | | POST | `/api/auth/refresh` | 刷新令牌轮换(旧 refresh 置撤销) | | POST | `/api/auth/logout` | 撤销 refresh(登出) | | GET | `/api/auth/me` | 当前登录用户 | ## 错误码(前后端共享同一份) | code | 含义 | | --------------------- | ------------------- | | 200 | 成功 | | 40000 | 参数缺失 / 非法 | | 40100 / 40101 / 40102 | 未登录 / 已过期 / 无效 | | 40103 / 40104 | 账号禁用 / 锁定 | | 40110 / 40111 / 40112 | 账号不存在 / 密码错误 / 刷新无效 | | 42900 / 50000 | 限流 / 系统错误 | ## 边界与已知限制(如实说明) * **DNS 重绑定**:URL 安全校验发生在提交时的一次 DNS 解析,与真正连接存在重绑定窗口;彻底闭合需在连接层 pin 住已校验的 IP(使用方连接层增强项)。 * **refresh 过期清理**:撤销只置位不物理删除,过期行需定期清理(后续可加清理任务)。 * **登录页覆盖**:默认 login.html 为中性实现,业务系统建议复制改样式(保持表单 id)。 ## 测试 后端(Java,真实 SQLite + 真实端口 E2E): ``` mvn test ``` **166 项全绿**:core 46 + starter 93 + security-ext 9 + demo 端到端 18。覆盖: * 安全核心:私网 / 回环 / 通配 / 链路本地 / 组播 / IPv6 唯一本地、URL 校验(scheme 大小写 / IPv6 字面量 /query 干扰)、路径防护(prefix 兄弟目录回归 / 根目录 / 相对路径 / 超长路径) * 认证:JWT 签发 / 验签 / 篡改 / 过期 / 跨密钥 / **并发签发唯一性 / 多实例验签**、密钥加载(base64/PEM/ 非法)、登录 / 锁定 / 禁用全场景(含 **max-fail=1 一次即锁**)、refresh 签发 / 轮换 / 过期 / 撤销 * 契约:错误码全局唯一、R\ 语义 + **JSON 序列化形态(code/msg/data)**、**前端 api.js 与后端错误码一致性**、全局异常 8 类、过滤器、CurrentUser、@Valid 契约 * 配置与装配:WebProperties 前缀绑定 / 默认值、WebAutoConfiguration 开关、AuthAutoConfiguration 接线、**双库时间映射转换器** * 安全扩展:异常映射、advice 优先级、条件装配 * 端到端:登录 / 401 / 刷新轮换 / 登出 / 防暴力 / SSRF / 路径穿越 / 参数校验 / 首页放行 / **认证关闭模式(openChain 全放行)** 前端(api.js/login.js,Node 内置 test runner,零依赖): ``` cd frontend-tests && node --test ``` **19 项全绿**:登录存 token / 自动 Bearer / 请求体序列化 /params 拼接 / **401 续期重放 / 并发单飞刷新 / 刷新失败清理与 onUnauthorized** / 登出 / 错误分发 / 登录页空输入拦截 / 成功跳转与 onSuccess / 失败提示与按钮防重复提交。 ## 路线图 * [x] v1.0 认证层:R\ + 全局异常 + JWT 双令牌 + 用户管理 + 前端 api.js + 安全扩展 * [ ] 阶段二:授权层(权限矩阵:用户 - 角色 - 权限点,线上可配置) * [ ] downloadHack 迁移(冻结期,待本脚手架测试全绿后执行) ## 文档 * 设计存档 / 待办 / 实测记录 / 迁移评估:[DEVELOPMENT.md](./DEVELOPMENT.md)