Commit 980127b4 by DaiJiezhang

Merge branch 'codex/merge-unmerged-auth-commits' into 'master'

修复:保留接口真实报错并限制手机号输入

See merge request !7
parents 78568a64 c6787499
......@@ -3,14 +3,16 @@ import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.util.Collections;
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class AuthTokenFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(AuthTokenFilter.class);
private final AuthTokenService tokens; private final SystemUserMapper users; private final PagePermissionService permissions;
/** 代码作用(白话):接收令牌、用户表和权限服务;关联文件:SecurityConfig.java。关联逻辑(调用链/数据流):Security Filter Chain -> 本过滤器 -> 用户表/权限。 */
/** 代码作用(白话):接收令牌解析、用户读取和权限计算依赖,用于把浏览器 Cookie 恢复成当前登录身份;关联文件:AuthTokenService.java、SystemUserMapper.java、PagePermissionService.java;关联逻辑(调用链/数据流):SecurityConfig 安全链 -> 本过滤器 -> Cookie/JWT -> 用户记录 -> SecurityContext。 */
public AuthTokenFilter(AuthTokenService tokens, SystemUserMapper users, PagePermissionService permissions) { this.tokens=tokens; this.users=users; this.permissions=permissions; }
/** 代码作用(白话):跳过登录和 CSRF 令牌接口,避免公开的初始化请求被自身拦截;关联文件:AuthController.java。关联逻辑(调用链/数据流):/api/auth/login 或 /api/auth/csrf -> Controller,不进入验签。 */
/** 代码作用(白话):跳过登录和 CSRF 初始化接口,避免公开入口被会话恢复逻辑拦截;关联文件:AuthController.java、SecurityConfig.java;关联逻辑(调用链/数据流):/api/auth/login 或 /api/auth/csrf -> Controller,其他接口 -> doFilterInternal()。 */
@Override protected boolean shouldNotFilter(HttpServletRequest request) { return "/api/auth/login".equals(request.getRequestURI()) || "/api/auth/csrf".equals(request.getRequestURI()); }
/** 代码作用(白话):将有效 Cookie 转成 Spring Security 身份并二次核验账号状态和版本;关联文件:AuthTokenService.java、SystemUserEntity.java。关联逻辑(调用链/数据流):请求 Cookie -> JWT -> 用户表 -> SecurityContext -> Controller。 */
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { String token=tokens.extract(request.getCookies()); if (token != null) { AuthPrincipal principal=tokens.parse(token); SystemUserEntity user=users.selectById(principal.userId()); if (user != null && "ACTIVE".equals(user.getStatus()) && user.getAuthVersion()!=null && user.getAuthVersion()==principal.authVersion()) { var authentication=new UsernamePasswordAuthenticationToken(principal, null, Collections.singleton(new SimpleGrantedAuthority("ROLE_"+principal.roleCode()))); authentication.setDetails(permissions.effectivePermissions(user)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception ignored) { SecurityContextHolder.clearContext(); } chain.doFilter(request,response); }
/** 代码作用(白话):读取会话 Cookie,验证令牌和账号状态后建立当前请求的登录身份;失败时保留 401 边界并输出不含凭证的诊断类别;关联文件:AuthTokenService.java、SystemUserEntity.java、SecurityConfig.java;关联逻辑(调用链/数据流):受保护请求 -> Cookie -> JWT -> 用户状态/版本 -> SecurityContext -> Controller 或统一 401。 */
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { String token=tokens.extract(request.getCookies()); if (token == null) log.info("AUTH_SESSION_MISSING method={} path={}", request.getMethod(), request.getRequestURI()); else { AuthPrincipal principal=tokens.parse(token); SystemUserEntity user=users.selectById(principal.userId()); if (user != null && "ACTIVE".equals(user.getStatus()) && user.getAuthVersion()!=null && user.getAuthVersion()==principal.authVersion()) { var authentication=new UsernamePasswordAuthenticationToken(principal, null, Collections.singleton(new SimpleGrantedAuthority("ROLE_"+principal.roleCode()))); authentication.setDetails(permissions.effectivePermissions(user)); SecurityContextHolder.getContext().setAuthentication(authentication); } else { SecurityContextHolder.clearContext(); log.info("AUTH_SESSION_REJECTED reason=USER_STATE_OR_VERSION method={} path={}", request.getMethod(), request.getRequestURI()); } } } catch (Exception error) { SecurityContextHolder.clearContext(); log.warn("AUTH_SESSION_ERROR type={} method={} path={}", error.getClass().getSimpleName(), request.getMethod(), request.getRequestURI()); } chain.doFilter(request,response); }
}
package com.xyw.console.common;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 文件用途(白话):把未被业务模块处理的数据库和服务器异常统一转换为页面可展示的 JSON 错误,避免浏览器把非认证故障误认为未登录。 */
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {
/** 代码作用(白话):把数据库唯一约束冲突转换成重复数据提示;关联文件:PhoneAssetService.java、phone-api-client.js;关联逻辑(调用链/数据流):新增或保存 -> DuplicateKeyException -> 本方法 -> ApiResponse -> 页面提示。 */
@ExceptionHandler(DuplicateKeyException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiResponse<Void> duplicate(DuplicateKeyException error) {
String detail = exceptionDetails(error);
String message = detail.contains("uk_phone_asset_phone_number") ? "手机号已存在,请勿重复新增" : "数据已存在,请勿重复提交";
return ApiResponse.error(409, message);
}
/** 代码作用(白话):把字段缺失等 SQL 结构错误转换成数据库迁移提示;关联文件:WecomAccountService.java、V2__add_wecom_phone_link_mode.sql;关联逻辑(调用链/数据流):业务写入 -> BadSqlGrammarException -> 本方法 -> ApiResponse -> 页面提示。 */
@ExceptionHandler(BadSqlGrammarException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> badSqlGrammar(BadSqlGrammarException error) {
return ApiResponse.error(500, "数据库字段未同步,请完成数据库迁移后重试");
}
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> unexpected(Exception error) {
return ApiResponse.error(500, "服务器处理失败,请稍后重试");
}
/** 代码作用(白话):拼接异常及其原因链的文字,用于识别数据库约束名称而不向浏览器返回底层细节;关联文件:GlobalExceptionHandlerTest.java、PhoneAssetService.java;关联逻辑(调用链/数据流):数据库驱动异常 -> Spring 包装异常 -> 本方法识别约束 -> 业务错误消息。 */
private String exceptionDetails(Throwable error) {
StringBuilder details = new StringBuilder();
for (Throwable current = error; current != null; current = current.getCause()) details.append(' ').append(current.getMessage());
return details.toString();
}
}
-- 代码作用(白话):为企微账号表补齐手机号关联方式字段,避免新增企微账号时写入不存在的列。
-- 关联文件:WecomAccountEntity.java、WecomAccountService.java。
-- 关联逻辑(调用链/数据流):企微新增表单 -> WecomAccountService.create() -> WecomAccountEntity.phoneLinkMode -> as_wecom_account.phone_link_mode。
-- 执行前必须通过 information_schema 确认 as_wecom_account 存在且 phone_link_mode 不存在;MySQL 不支持安全的 ADD COLUMN IF NOT EXISTS。
ALTER TABLE as_wecom_account
ADD COLUMN phone_link_mode VARCHAR(16) NULL COMMENT '手机号关联方式:EXISTING=已有手机号,CREATED=自动创建手机号'
AFTER phone_asset_id;
-- 回滚说明:确认没有应用依赖该字段且完成数据库备份后,执行:
-- ALTER TABLE as_wecom_account DROP COLUMN phone_link_mode;
package com.xyw.console.common;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.sql.SQLIntegrityConstraintViolationException;
import java.sql.SQLSyntaxErrorException;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.BadSqlGrammarException;
/** 文件用途(白话):验证未处理的数据库异常会被转换成页面可以直接展示的统一错误消息。 */
class GlobalExceptionHandlerTest {
/** 代码作用(白话):证明手机号唯一约束冲突不会泄露底层 SQL,而是返回手机号已存在;关联文件:GlobalExceptionHandler.java、PhoneAssetService.java;关联逻辑(调用链/数据流):手机号新增 -> DuplicateKeyException -> Advice -> ApiResponse -> 页面提示。 */
@Test void phoneNumberDuplicateReturnsBusinessMessage() {
GlobalExceptionHandler handler = new GlobalExceptionHandler();
DuplicateKeyException error = new DuplicateKeyException("duplicate", new SQLIntegrityConstraintViolationException("Duplicate entry for key 'as_phone_asset.uk_phone_asset_phone_number_delete_time'"));
ApiResponse<Void> response = handler.duplicate(error);
assertEquals(409, response.code());
assertEquals("手机号已存在,请勿重复新增", response.message());
}
/** 代码作用(白话):证明缺失数据库列会返回字段未同步提示,而不是被页面误判为登录问题;关联文件:GlobalExceptionHandler.java、WecomAccountService.java;关联逻辑(调用链/数据流):企微新增 -> BadSqlGrammarException -> Advice -> ApiResponse -> 页面提示。 */
@Test void missingColumnReturnsMigrationMessage() {
GlobalExceptionHandler handler = new GlobalExceptionHandler();
BadSqlGrammarException error = new BadSqlGrammarException("insert", "INSERT", new SQLSyntaxErrorException("Unknown column 'phone_link_mode' in 'field list'"));
ApiResponse<Void> response = handler.badSqlGrammar(error);
assertEquals(500, response.code());
assertEquals("数据库字段未同步,请完成数据库迁移后重试", response.message());
}
}
......@@ -9,7 +9,7 @@ function notifyExpiredSession() { if (typeof window !== 'undefined') window.disp
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login') return csrfHeader(); const existing = csrfHeader(); if (Object.keys(existing).length) return existing; const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); const refreshed = csrfHeader(); if (!Object.keys(refreshed).length) throw new Error('安全校验初始化失败'); return refreshed; }
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export async function request(path, options = {}) { const { headers: suppliedHeaders = {}, body, ...requestOptions } = options; const method = (requestOptions.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const headers = { ...csrf, ...suppliedHeaders }; if (!isFormDataBody(body) && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(path, { ...requestOptions, body, credentials: 'include', headers }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && path !== '/api/auth/login' && path !== '/api/auth/me') notifyExpiredSession(); if (!response.ok || payload.code !== 200) throw createRequestError(payload.message || '请求失败', response.ok ? payload.code : response.status); return payload.data; }
export async function request(path, options = {}) { const { headers: suppliedHeaders = {}, body, ...requestOptions } = options; const method = (requestOptions.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const headers = { ...csrf, ...suppliedHeaders }; if (!isFormDataBody(body) && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(path, { ...requestOptions, body, credentials: 'include', headers }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && payload.message === '请先登录' && path !== '/api/auth/login' && path !== '/api/auth/me') notifyExpiredSession(); if (!response.ok || payload.code !== 200) throw createRequestError(payload.message || '请求失败', response.ok ? payload.code : response.status); return payload.data; }
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
export function login(credentials) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) }); }
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
......
......@@ -295,7 +295,7 @@ export default {
<el-dialog v-model="dialogVisible" class="phone-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
<el-input v-model="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @input="limitPhoneNumber" @paste="handlePhonePaste">
<el-input :model-value="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @update:model-value="limitPhoneNumber" @paste="handlePhonePaste">
<template #suffix><span class="phone-asset-modal__character-count" aria-live="polite">{{ form.phoneNumber.length }}/11</span></template>
</el-input>
</el-form-item>
......
schema: spec-driven
created: 2026-08-04
## Context
系统使用 JWT 作为无状态会话票据,并通过 `XYW_SESSION` HttpOnly Cookie 让浏览器在后续请求中自动携带票据。现有 `AuthTokenService` 已按 `xyw.auth.cookie-secure` 配置正确签发 HTTP/HTTPS Cookie。当前 IntelliJ 运行配置却把项目目录设置成 Spring profile,且未显式设定工作目录,可能使根目录 `.env` 未按预期被 Spring 原生导入。
## Goals / Non-Goals
**Goals:**
- 本地后端从项目根目录启动,并由 `application.yml` 的 Spring 原生配置导入机制读取根目录 `.env`
- 移除无效的 Spring profile 目录值,避免本地运行环境混乱。
- 保持现有 HTTP/HTTPS Cookie 策略及登录接口兼容。
**Non-Goals:**
- 不更换 JWT 算法、Cookie 名称、会话时长、接口路径或响应信封。
- 不修改数据库结构、用户权限模型、前端存储方式或认证业务代码。
- 不启用 EnvFile 插件,避免它与 Spring 原生 `.env` 导入重复。
## Decisions
### Spring 原生加载根目录 `.env`
IntelliJ 的 Spring Boot 运行配置移除 `ACTIVE_PROFILES=F:\Project\xyw_console`,并设置 `WORKING_DIRECTORY=$PROJECT_DIR$`。这让 `application.yml` 中的 `optional:file:.env[.properties]` 从项目根目录解析;不启用 EnvFile 插件。
备选方案是启用 EnvFile 插件,但当前配置没有声明文件条目,且 Spring 已具备原生导入机制;双重注入会使配置优先级难以判断,因此不采用。
### 以登录回归结果决定是否增加认证诊断
修正启动配置后先进行真实登录验证。若 `/api/auth/me` 仍返回 401,再新增不含敏感信息的过滤器诊断与自动化测试;在现有 Cookie 策略已正确的情况下,不预先增加无关认证代码。
## Risks / Trade-offs
- [根目录 `.env` 缺失或内容不完整] → 后端启动后验证必要认证配置是否存在,但不得输出密钥内容。
- [本地 `cookie-secure` 被外部环境覆盖为 true] → 使用同一 `localhost` 地址重新登录并查看 Cookie 属性;必要时修正外部环境变量。
- [用户在 `localhost` 和 `127.0.0.1` 间切换] → Cookie 属于不同主机存储;文档和手工验证统一使用 `localhost`
- [认证失败原因来自数据库或密钥配置] → 保留统一 401,服务端日志提供安全诊断,不改变客户端安全边界。
## Migration Plan
1. 修正本地 IntelliJ 启动配置并保留 EnvFile 禁用状态。
2. 重启本地后端,统一通过 `http://localhost:5173/asset/` 重新登录。
3. 验证 `/api/auth/me` 及受保护的 GET/POST 接口。
4. 如出现问题,回滚本地运行配置;认证代码、接口和数据均无需迁移。
## Open Questions
- 当前运行中的后端实例是否被外部环境变量设置为 `XYW_AUTH_COOKIE_SECURE=true`,需要在重启后的响应 Cookie 属性中确认。
## Why
在本地 `http://localhost:5173` 登录成功后,后续受保护页面仍可能收到“请先登录”的 401 响应,导致用户无法使用整个后台。当前认证过滤器会静默吞掉所有令牌恢复异常,无法区分 Cookie 未被保存、令牌无效或用户状态变化等原因,因此需要同时修复本地会话兼容性和可诊断性。
## What Changes
- 修正 IntelliJ 本地后端启动配置:不再把项目目录作为 Spring profile,并固定以项目根目录作为工作目录,使 Spring 原生加载根目录 `.env`
- 保持现有 Cookie 策略;现有实现已由 `xyw.auth.cookie-secure` 区分本地 HTTP 与生产 HTTPS。
- 登录后仍出现 401 时,新增安全认证诊断,记录认证失败类别而不记录敏感凭证。
- 保持现有登录、登出、接口响应格式、DTO 与数据库结构兼容。
## Capabilities
### New Capabilities
- `local-auth-session-recovery`: 本地 HTTP 环境下登录 Cookie 的签发、发送与后端身份恢复,以及安全的失败诊断。
### Modified Capabilities
- 无。
## Impact
- 影响本地 IntelliJ 启动配置 `.idea/workspace.xml`
- 影响 `AuthTokenFilter` 的服务端诊断日志;不修改 HTTP 接口、DTO、数据库字段或第三方依赖。
- 本地开发环境需要统一使用 `http://localhost:5173/asset/`;生产 HTTPS 环境继续使用安全 Cookie。
## ADDED Requirements
### Requirement: Local HTTP session cookie recovery
系统在 `xyw.auth.cookie-secure=false` 时,登录成功后 MUST 签发不带 `Secure` 属性的 `XYW_SESSION` HttpOnly Cookie,使浏览器能够在同一 `localhost` HTTP 站点的后续受保护请求中发送该 Cookie。
#### Scenario: Login over local HTTP
- **WHEN** 启用本地 HTTP Cookie 配置的有效用户完成登录
- **THEN** 响应 MUST 包含带有 `HttpOnly``Path=/``SameSite=Lax`、且不带 `Secure``XYW_SESSION` Cookie
### Requirement: HTTPS session cookie protection
系统在 `xyw.auth.cookie-secure=true` 时,登录成功后 MUST 签发带 `Secure` 属性的 `XYW_SESSION` HttpOnly Cookie。
#### Scenario: Login for HTTPS deployment
- **WHEN** 启用 HTTPS Cookie 配置的有效用户完成登录
- **THEN** 响应 MUST 包含带 `Secure``HttpOnly``Path=/``SameSite=Lax``XYW_SESSION` Cookie
### Requirement: Safe authentication recovery diagnostics
系统 MUST 在令牌恢复失败时清空认证上下文并保持既有的 401 响应边界,同时在服务端记录不含敏感认证数据的失败类别。
#### Scenario: Invalid session token
- **WHEN** 受保护请求携带无法解析的 `XYW_SESSION` Cookie
- **THEN** 系统 MUST 不建立认证身份、MUST 返回既有 401 响应,并记录不含 Cookie 值、JWT、密码或密钥的令牌解析失败类别
#### Scenario: Valid session token
- **WHEN** 受保护请求携带有效且对应活动账号、认证版本一致的 `XYW_SESSION` Cookie
- **THEN** 系统 MUST 建立当前认证身份并允许请求继续进入受保护的控制器
## 1. 本地启动配置
- [x] 1.1 修改 `.idea/workspace.xml`(文件用途:保存本地 IntelliJ 后端启动配置),移除错误的项目目录 profile,并设置项目根目录为工作目录;配置项关联 `backend/src/main/resources/application.yml`,数据流为 IntelliJ 启动 -> Spring 配置导入 -> `.env`
- [x] 1.2 保持 EnvFile 插件禁用(文件用途:避免重复注入环境变量),由 Spring 原生 `spring.config.import` 加载根目录 `.env`;不新增业务方法,因此无方法注释要求。
## 2. 重启与验证
- [x] 2.1 重启后端并确认启动命令不再携带项目目录形式的 `spring.profiles.active`
- [x] 2.2 修改 `AuthTokenFilter.java`(文件用途:从请求 Cookie 恢复 Spring Security 身份),记录 `AUTH_SESSION_MISSING``AUTH_SESSION_REJECTED``AUTH_SESSION_ERROR` 等不含敏感数据的诊断类别;为构造方法、`shouldNotFilter``doFilterInternal` 编写完整新手注释:代码作用、关联文件、关联逻辑。
- [ ] 2.3 统一使用 `http://localhost:5173/asset/`,重新登录后根据诊断日志验证 `/api/auth/me`、受保护 GET 与受保护 POST 的认证失败原因。
- [ ] 2.4 确认本地 `XYW_AUTH_COOKIE_SECURE=false`、生产 HTTPS 为 `true`;基于诊断结果再实施单一根因修复。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment