From dd6ca8756d3e72fae0f868491284e165e4a73659 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:45:07 +0800
Subject: [PATCH 01/20] fix(auth): stop exposing refresh token in login JSON
---
shiqian-user/src/main/java/com/shiqian/user/dto/LoginVO.java | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/shiqian-user/src/main/java/com/shiqian/user/dto/LoginVO.java b/shiqian-user/src/main/java/com/shiqian/user/dto/LoginVO.java
index d164670..444495e 100644
--- a/shiqian-user/src/main/java/com/shiqian/user/dto/LoginVO.java
+++ b/shiqian-user/src/main/java/com/shiqian/user/dto/LoginVO.java
@@ -1,5 +1,6 @@
package com.shiqian.user.dto;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.util.Set;
@@ -9,6 +10,10 @@ public class LoginVO {
private String accessToken;
+ /**
+ * 仅供服务端写入 HttpOnly Cookie,禁止序列化到 JSON,避免浏览器 JavaScript 获取长期刷新令牌。
+ */
+ @JsonIgnore
private String refreshToken;
private Long userId;
From 2bb37eae5fedd1519c200a898863fe115b7fb03c Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:45:22 +0800
Subject: [PATCH 02/20] feat(auth): persist refresh token in HttpOnly cookie
---
.../security/RefreshTokenCookieService.java | 88 +++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 shiqian-user/src/main/java/com/shiqian/user/security/RefreshTokenCookieService.java
diff --git a/shiqian-user/src/main/java/com/shiqian/user/security/RefreshTokenCookieService.java b/shiqian-user/src/main/java/com/shiqian/user/security/RefreshTokenCookieService.java
new file mode 100644
index 0000000..3361707
--- /dev/null
+++ b/shiqian-user/src/main/java/com/shiqian/user/security/RefreshTokenCookieService.java
@@ -0,0 +1,88 @@
+package com.shiqian.user.security;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+
+/**
+ * Refresh Token 的唯一浏览器持久化边界。
+ *
+ *
Refresh Token 只允许存在 HttpOnly Cookie 中,不通过 JSON 暴露给前端 JavaScript。
+ */
+@Component
+public class RefreshTokenCookieService {
+
+ private final String cookieName;
+ private final boolean secure;
+ private final String sameSite;
+ private final String domain;
+ private final Duration maxAge;
+
+ public RefreshTokenCookieService(
+ @Value("${campushub.auth.refresh-cookie.name:campushub_refresh}") String cookieName,
+ @Value("${campushub.auth.refresh-cookie.secure:true}") boolean secure,
+ @Value("${campushub.auth.refresh-cookie.same-site:Lax}") String sameSite,
+ @Value("${campushub.auth.refresh-cookie.domain:}") String domain,
+ @Value("${jwt.refresh-token-expiration:604800000}") long refreshTokenExpirationMs) {
+ this.cookieName = cookieName;
+ this.secure = secure;
+ this.sameSite = normalizeSameSite(sameSite);
+ this.domain = domain;
+ this.maxAge = Duration.ofMillis(Math.max(0L, refreshTokenExpirationMs));
+ }
+
+ public String read(HttpServletRequest request) {
+ Cookie[] cookies = request.getCookies();
+ if (cookies == null) {
+ return null;
+ }
+ for (Cookie cookie : cookies) {
+ if (cookieName.equals(cookie.getName()) && StringUtils.hasText(cookie.getValue())) {
+ return cookie.getValue();
+ }
+ }
+ return null;
+ }
+
+ public void write(HttpServletResponse response, String refreshToken) {
+ if (!StringUtils.hasText(refreshToken)) {
+ throw new IllegalArgumentException("refreshToken 不能为空");
+ }
+ response.addHeader(HttpHeaders.SET_COOKIE, build(refreshToken, maxAge).toString());
+ }
+
+ public void clear(HttpServletResponse response) {
+ response.addHeader(HttpHeaders.SET_COOKIE, build("", Duration.ZERO).toString());
+ }
+
+ private ResponseCookie build(String value, Duration age) {
+ ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(cookieName, value)
+ .httpOnly(true)
+ .secure(secure)
+ .sameSite(sameSite)
+ .path("/api/user")
+ .maxAge(age);
+ if (StringUtils.hasText(domain)) {
+ builder.domain(domain.trim());
+ }
+ return builder.build();
+ }
+
+ private String normalizeSameSite(String value) {
+ if (!StringUtils.hasText(value)) {
+ return "Lax";
+ }
+ return switch (value.trim().toLowerCase()) {
+ case "strict" -> "Strict";
+ case "none" -> "None";
+ default -> "Lax";
+ };
+ }
+}
From 70c37d10a0f975401e208dee02ba56b7684758a0 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:45:39 +0800
Subject: [PATCH 03/20] feat(auth): validate browser origins for cookie auth
endpoints
---
.../user/filter/BrowserAuthOriginFilter.java | 68 +++++++++++++++++++
1 file changed, 68 insertions(+)
create mode 100644 shiqian-user/src/main/java/com/shiqian/user/filter/BrowserAuthOriginFilter.java
diff --git a/shiqian-user/src/main/java/com/shiqian/user/filter/BrowserAuthOriginFilter.java b/shiqian-user/src/main/java/com/shiqian/user/filter/BrowserAuthOriginFilter.java
new file mode 100644
index 0000000..4e5ccfe
--- /dev/null
+++ b/shiqian-user/src/main/java/com/shiqian/user/filter/BrowserAuthOriginFilter.java
@@ -0,0 +1,68 @@
+package com.shiqian.user.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 对会创建/轮换浏览器认证 Cookie 的接口执行精确 Origin 校验。
+ *
+ * 当 Refresh Cookie 使用 SameSite=None 以支持 GitHub Pages 等跨站前端时,
+ * CORS 本身不能替代 CSRF 防护;该过滤器阻止不可信网页触发登录/刷新/退出。
+ */
+@Component
+public class BrowserAuthOriginFilter extends OncePerRequestFilter {
+
+ private static final Set PROTECTED_PATHS = Set.of(
+ "/api/user/login",
+ "/api/user/refresh",
+ "/api/user/logout"
+ );
+
+ private final Set allowedOrigins;
+
+ public BrowserAuthOriginFilter(
+ @Value("${campushub.auth.allowed-origins:http://localhost:5173,http://127.0.0.1:5173}")
+ String configuredOrigins) {
+ this.allowedOrigins = Arrays.stream(configuredOrigins.split(","))
+ .map(String::trim)
+ .filter(StringUtils::hasText)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ return "OPTIONS".equalsIgnoreCase(request.getMethod())
+ || !PROTECTED_PATHS.contains(request.getRequestURI());
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ String origin = request.getHeader("Origin");
+
+ // curl、服务端 SDK 等非浏览器客户端通常没有 Origin;仍允许按现有 API 方式调用。
+ if (!StringUtils.hasText(origin) || allowedOrigins.contains(origin)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setCharacterEncoding("UTF-8");
+ response.setContentType("application/json;charset=UTF-8");
+ response.getWriter().write(
+ "{\"code\":403,\"message\":\"不可信的请求来源\",\"data\":null,\"success\":false}");
+ }
+}
From 865ba4da6f8af30119e57d17aa6f1c94a1773009 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:45:53 +0800
Subject: [PATCH 04/20] feat(security): enforce service key for every internal
endpoint
---
.../user/filter/InternalServiceKeyFilter.java | 47 +++++++++++++++++++
1 file changed, 47 insertions(+)
create mode 100644 shiqian-user/src/main/java/com/shiqian/user/filter/InternalServiceKeyFilter.java
diff --git a/shiqian-user/src/main/java/com/shiqian/user/filter/InternalServiceKeyFilter.java b/shiqian-user/src/main/java/com/shiqian/user/filter/InternalServiceKeyFilter.java
new file mode 100644
index 0000000..98027f6
--- /dev/null
+++ b/shiqian-user/src/main/java/com/shiqian/user/filter/InternalServiceKeyFilter.java
@@ -0,0 +1,47 @@
+package com.shiqian.user.filter;
+
+import com.shiqian.common.exception.BusinessException;
+import com.shiqian.common.user.InternalApiHeaders;
+import com.shiqian.user.security.InternalServiceKeyValidator;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * 统一保护所有 /internal/** 服务间接口,避免 Controller 新增端点时遗漏鉴权。
+ */
+@Component
+@RequiredArgsConstructor
+public class InternalServiceKeyFilter extends OncePerRequestFilter {
+
+ private final InternalServiceKeyValidator validator;
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String uri = request.getRequestURI();
+ return uri == null || !uri.startsWith("/internal/");
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ try {
+ validator.validate(request.getHeader(InternalApiHeaders.SERVICE_KEY));
+ filterChain.doFilter(request, response);
+ } catch (BusinessException ex) {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setCharacterEncoding("UTF-8");
+ response.setContentType("application/json;charset=UTF-8");
+ response.getWriter().write(
+ "{\"code\":403,\"message\":\"服务间调用凭据无效\",\"data\":null,\"success\":false}");
+ }
+ }
+}
From 5c62462bb49df698e3dbac167064e32fecb56e69 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:46:16 +0800
Subject: [PATCH 05/20] refactor(security): centralize internal and browser
auth filters
---
.../shiqian/user/config/SecurityConfig.java | 26 ++++++++++++-------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/shiqian-user/src/main/java/com/shiqian/user/config/SecurityConfig.java b/shiqian-user/src/main/java/com/shiqian/user/config/SecurityConfig.java
index cf10b28..abcdb22 100644
--- a/shiqian-user/src/main/java/com/shiqian/user/config/SecurityConfig.java
+++ b/shiqian-user/src/main/java/com/shiqian/user/config/SecurityConfig.java
@@ -1,10 +1,11 @@
package com.shiqian.user.config;
+import com.shiqian.user.filter.BrowserAuthOriginFilter;
+import com.shiqian.user.filter.InternalServiceKeyFilter;
import com.shiqian.user.filter.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -21,9 +22,16 @@
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
+ private final InternalServiceKeyFilter internalServiceKeyFilter;
+ private final BrowserAuthOriginFilter browserAuthOriginFilter;
- public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
+ public SecurityConfig(
+ JwtAuthenticationFilter jwtAuthenticationFilter,
+ InternalServiceKeyFilter internalServiceKeyFilter,
+ BrowserAuthOriginFilter browserAuthOriginFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
+ this.internalServiceKeyFilter = internalServiceKeyFilter;
+ this.browserAuthOriginFilter = browserAuthOriginFilter;
}
@Bean
@@ -47,18 +55,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/swagger-ui.html",
"/favicon.ico"
).permitAll()
- .requestMatchers(
- HttpMethod.POST,
- "/internal/users/public-profiles/batch"
- ).permitAll()
- .requestMatchers(
- HttpMethod.GET,
- "/internal/users/*/authorities"
- ).permitAll()
+ // /internal/** 由 InternalServiceKeyFilter 在进入 Controller 前统一鉴权。
+ .requestMatchers("/internal/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class)
+ .addFilterBefore(browserAuthOriginFilter,
+ JwtAuthenticationFilter.class)
+ .addFilterBefore(internalServiceKeyFilter,
+ JwtAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType("application/json;charset=UTF-8");
From 9d8e4be05a4ccbb3f8a558f2c937f6741566ac68 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:46:46 +0800
Subject: [PATCH 06/20] feat(auth): rotate refresh tokens through HttpOnly
cookie
---
.../user/controller/UserController.java | 81 +++++++++++++------
1 file changed, 57 insertions(+), 24 deletions(-)
diff --git a/shiqian-user/src/main/java/com/shiqian/user/controller/UserController.java b/shiqian-user/src/main/java/com/shiqian/user/controller/UserController.java
index cf69790..404b04b 100644
--- a/shiqian-user/src/main/java/com/shiqian/user/controller/UserController.java
+++ b/shiqian-user/src/main/java/com/shiqian/user/controller/UserController.java
@@ -1,42 +1,47 @@
package com.shiqian.user.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.shiqian.common.result.Result;
+import com.shiqian.common.exception.BusinessException;
import com.shiqian.common.ratelimit.DistributedRateLimit;
import com.shiqian.common.ratelimit.RateLimitKeyMode;
+import com.shiqian.common.result.Result;
+import com.shiqian.common.security.LoginUser;
import com.shiqian.common.security.SecurityUtil;
+import com.shiqian.user.dto.ChangePasswordDTO;
import com.shiqian.user.dto.LoginDTO;
import com.shiqian.user.dto.LoginVO;
import com.shiqian.user.dto.RegisterDTO;
-import com.shiqian.user.dto.RefreshTokenDTO;
import com.shiqian.user.dto.UpdateUserDTO;
import com.shiqian.user.dto.UserInfoVO;
import com.shiqian.user.dto.UserRoleUpdateDTO;
import com.shiqian.user.dto.UserStatusUpdateDTO;
-import com.shiqian.user.dto.ChangePasswordDTO;
-import com.shiqian.common.security.LoginUser;
+import com.shiqian.user.security.RefreshTokenCookieService;
import com.shiqian.user.service.UserService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RequestHeader;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.security.SecurityRequirement;
-import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.RestController;
-import org.springframework.validation.annotation.Validated;
-import jakarta.validation.constraints.Max;
-import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.Positive;
import java.util.HashMap;
import java.util.Map;
@@ -50,6 +55,7 @@
public class UserController {
private final UserService userService;
+ private final RefreshTokenCookieService refreshTokenCookieService;
@Operation(summary = "健康检查")
@GetMapping("/health")
@@ -71,19 +77,31 @@ public Result register(@RequestBody @Valid RegisterDTO registerDTO) {
return Result.ok();
}
- @Operation(summary = "用户登录")
+ @Operation(summary = "用户登录(refreshToken 仅写入 HttpOnly Cookie)")
@PostMapping("/login")
@DistributedRateLimit(name = "user:login", limit = 10, windowSeconds = 60, keyMode = RateLimitKeyMode.IP)
- public Result login(@RequestBody @Valid LoginDTO loginDTO) {
+ public Result login(
+ @RequestBody @Valid LoginDTO loginDTO,
+ HttpServletResponse response) {
LoginVO loginVO = userService.login(loginDTO);
+ refreshTokenCookieService.write(response, loginVO.getRefreshToken());
+ disableSensitiveResponseCaching(response);
return Result.ok(loginVO);
}
- @Operation(summary = "刷新访问令牌(使用 refreshToken 重新签发 accessToken + refreshToken)")
+ @Operation(summary = "刷新访问令牌(refreshToken 从 HttpOnly Cookie 读取并轮换)")
@PostMapping("/refresh")
@DistributedRateLimit(name = "user:refresh", limit = 30, windowSeconds = 60, keyMode = RateLimitKeyMode.IP)
- public Result refresh(@RequestBody @Valid RefreshTokenDTO body) {
- LoginVO loginVO = userService.refresh(body.getRefreshToken());
+ public Result refresh(
+ HttpServletRequest request,
+ HttpServletResponse response) {
+ String refreshToken = refreshTokenCookieService.read(request);
+ if (!StringUtils.hasText(refreshToken)) {
+ throw new BusinessException(401, "refreshToken 缺失或已过期");
+ }
+ LoginVO loginVO = userService.refresh(refreshToken);
+ refreshTokenCookieService.write(response, loginVO.getRefreshToken());
+ disableSensitiveResponseCaching(response);
return Result.ok(loginVO);
}
@@ -91,14 +109,20 @@ public Result refresh(@RequestBody @Valid RefreshTokenDTO body) {
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/logout")
public Result logout(
- @RequestHeader(value = "Authorization", required = false) String authorization) {
+ @RequestHeader(value = "Authorization", required = false) String authorization,
+ HttpServletResponse response) {
Long userId = SecurityUtil.getCurrentUserId();
String accessToken = authorization != null && authorization.startsWith("Bearer ")
? authorization.substring("Bearer ".length())
: null;
- userService.logout(userId, accessToken);
- SecurityContextHolder.clearContext();
- return Result.ok();
+ try {
+ userService.logout(userId, accessToken);
+ SecurityContextHolder.clearContext();
+ return Result.ok();
+ } finally {
+ refreshTokenCookieService.clear(response);
+ disableSensitiveResponseCaching(response);
+ }
}
@Operation(summary = "更新当前用户信息")
@@ -114,9 +138,13 @@ public Result updateCurrentUser(@RequestBody @Valid UpdateUserDTO updateUs
@Operation(summary = "修改当前用户密码(修改后所有旧令牌失效)")
@SecurityRequirement(name = "bearerAuth")
@PutMapping("/me/password")
- public Result changePassword(@RequestBody @Valid ChangePasswordDTO changePasswordDTO) {
+ public Result changePassword(
+ @RequestBody @Valid ChangePasswordDTO changePasswordDTO,
+ HttpServletResponse response) {
Long userId = SecurityUtil.getCurrentUserId();
userService.changePassword(userId, changePasswordDTO);
+ refreshTokenCookieService.clear(response);
+ disableSensitiveResponseCaching(response);
return Result.ok();
}
@@ -165,4 +193,9 @@ public Result updateUserRole(
userService.updateUserRole(id, body.getRole(), operatorId);
return Result.ok();
}
+
+ private void disableSensitiveResponseCaching(HttpServletResponse response) {
+ response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
+ response.setHeader(HttpHeaders.PRAGMA, "no-cache");
+ }
}
From 0ebe7dcafc1882ab5891a6acf52f4a81aa2ad431 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:46:58 +0800
Subject: [PATCH 07/20] refactor(security): remove controller-level internal
auth convention
---
.../user/controller/InternalUserController.java | 11 +----------
1 file changed, 1 insertion(+), 10 deletions(-)
diff --git a/shiqian-user/src/main/java/com/shiqian/user/controller/InternalUserController.java b/shiqian-user/src/main/java/com/shiqian/user/controller/InternalUserController.java
index ec4effb..a82bbef 100644
--- a/shiqian-user/src/main/java/com/shiqian/user/controller/InternalUserController.java
+++ b/shiqian-user/src/main/java/com/shiqian/user/controller/InternalUserController.java
@@ -3,9 +3,7 @@
import com.shiqian.common.result.Result;
import com.shiqian.common.security.AuthoritySnapshot;
import com.shiqian.common.user.BatchUserProfileRequest;
-import com.shiqian.common.user.InternalApiHeaders;
import com.shiqian.common.user.PublicUserProfile;
-import com.shiqian.user.security.InternalServiceKeyValidator;
import com.shiqian.user.service.RbacService;
import com.shiqian.user.service.UserService;
import io.swagger.v3.oas.annotations.Hidden;
@@ -16,7 +14,6 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -24,6 +21,7 @@
/**
* 仅供后端服务调用,不通过 Gateway 对外路由。
+ * 所有 /internal/** 请求由 InternalServiceKeyFilter 在进入 Controller 前统一鉴权。
*/
@Hidden
@RestController
@@ -33,23 +31,16 @@ public class InternalUserController {
private final UserService userService;
private final RbacService rbacService;
- private final InternalServiceKeyValidator serviceKeyValidator;
@PostMapping("/public-profiles/batch")
public Result> getPublicProfiles(
- @RequestHeader(value = InternalApiHeaders.SERVICE_KEY, required = false)
- String serviceKey,
@RequestBody @Valid BatchUserProfileRequest request) {
- serviceKeyValidator.validate(serviceKey);
return Result.ok(userService.getPublicProfiles(request.getUserIds()));
}
@GetMapping("/{userId}/authorities")
public Result getAuthorities(
- @RequestHeader(value = InternalApiHeaders.SERVICE_KEY, required = false)
- String serviceKey,
@PathVariable @Positive Long userId) {
- serviceKeyValidator.validate(serviceKey);
return Result.ok(rbacService.getAuthoritySnapshot(userId));
}
}
From 2ab9ca44269ae8ddb00e8d2d0da13b016259183f Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:47:28 +0800
Subject: [PATCH 08/20] config(auth): harden browser session defaults
---
shiqian-user/src/main/resources/application.yml | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/shiqian-user/src/main/resources/application.yml b/shiqian-user/src/main/resources/application.yml
index e862cb0..23b38bf 100644
--- a/shiqian-user/src/main/resources/application.yml
+++ b/shiqian-user/src/main/resources/application.yml
@@ -19,6 +19,14 @@ spring:
campushub:
internal:
service-key: ${INTERNAL_SERVICE_KEY:}
+ auth:
+ # 浏览器认证接口只接受这些精确 Origin。生产环境必须显式覆盖为真实前端域名。
+ allowed-origins: ${BROWSER_AUTH_ALLOWED_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
+ refresh-cookie:
+ name: ${REFRESH_TOKEN_COOKIE_NAME:campushub_refresh}
+ secure: ${REFRESH_TOKEN_COOKIE_SECURE:true}
+ same-site: ${REFRESH_TOKEN_COOKIE_SAME_SITE:Lax}
+ domain: ${REFRESH_TOKEN_COOKIE_DOMAIN:}
rbac:
authority-cache-ttl: ${RBAC_AUTHORITY_CACHE_TTL:24h}
@@ -28,10 +36,10 @@ mybatis-plus:
configuration:
map-underscore-to-camel-case: true
-# JWT 配置
+# JWT 配置:access token 短生命周期,refresh token 仅通过 HttpOnly Cookie 持久化。
jwt:
secret: ${JWT_SECRET}
- access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:7200000}
+ access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:1800000}
refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800000}
management:
From 220d80940f9ddcb56ae41bbd0f46f009c1b19fbf Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:47:43 +0800
Subject: [PATCH 09/20] config(auth): allow HttpOnly refresh cookie on local
HTTP
---
shiqian-user/src/main/resources/application-local.yml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/shiqian-user/src/main/resources/application-local.yml b/shiqian-user/src/main/resources/application-local.yml
index 133b41f..8cfdd62 100644
--- a/shiqian-user/src/main/resources/application-local.yml
+++ b/shiqian-user/src/main/resources/application-local.yml
@@ -9,6 +9,13 @@ spring:
server:
address: ${INTERNAL_SERVER_ADDRESS:127.0.0.1}
+campushub:
+ auth:
+ # localhost 开发通常使用 HTTP;生产 profile 不应继承这个覆盖。
+ refresh-cookie:
+ secure: false
+ same-site: Lax
+
management:
endpoints:
web:
From 39ca19c4e405c5415bdca1621d908159ec16109a Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:48:18 +0800
Subject: [PATCH 10/20] fix(frontend): keep access token in memory and refresh
via cookie
---
shiqian-frontend/src/api/client.ts | 52 +++++++++++++++++-------------
1 file changed, 29 insertions(+), 23 deletions(-)
diff --git a/shiqian-frontend/src/api/client.ts b/shiqian-frontend/src/api/client.ts
index 18fd3ce..fa89106 100644
--- a/shiqian-frontend/src/api/client.ts
+++ b/shiqian-frontend/src/api/client.ts
@@ -14,7 +14,8 @@ export interface PageResult {
const runtimeConfig = window.__SHIQIAN_CONFIG__ || {}
const API_BASE = runtimeConfig.apiBaseUrl || import.meta.env.VITE_API_BASE_URL || ''
-let refreshInFlight: Promise<{ accessToken: string; refreshToken: string }> | null = null
+let accessToken = ''
+let refreshInFlight: Promise<{ accessToken: string }> | null = null
let authFailureHandler: (() => void) | null = null
/** 由 Pinia store 注册:token 失效时同步清空登录态。 */
@@ -34,44 +35,43 @@ export function buildApiUrl(path: string, query?: Record) {
return buildUrl(path, query)
}
+/** Access Token 只保存在当前页面内存中,不持久化到 Web Storage。 */
export function getAccessToken() {
- return localStorage.getItem('shiqian_access_token') || ''
+ return accessToken
}
-export function getRefreshToken() {
- return localStorage.getItem('shiqian_refresh_token') || ''
-}
-
-export function setTokens(accessToken: string, refreshToken: string) {
- localStorage.setItem('shiqian_access_token', accessToken)
- localStorage.setItem('shiqian_refresh_token', refreshToken)
+export function setTokens(nextAccessToken: string, _legacyRefreshToken?: string) {
+ accessToken = nextAccessToken || ''
+ // 清理旧版本遗留的长期令牌,升级后不再向 Web Storage 写入任何认证 token。
+ localStorage.removeItem('shiqian_access_token')
+ localStorage.removeItem('shiqian_refresh_token')
}
export function clearTokens() {
+ accessToken = ''
localStorage.removeItem('shiqian_access_token')
localStorage.removeItem('shiqian_refresh_token')
}
-export async function refreshAccessToken(): Promise<{ accessToken: string; refreshToken: string }> {
+/**
+ * 使用 HttpOnly Cookie 中的 refresh token 轮换会话。
+ * JavaScript 永远不读取 refresh token;并发 401 共享同一个刷新请求。
+ */
+export async function refreshAccessToken(): Promise<{ accessToken: string }> {
if (refreshInFlight) return refreshInFlight
const task = (async () => {
- const refreshToken = getRefreshToken()
- if (!refreshToken) {
- throw new Error('无 refreshToken')
- }
- // 多文件并发遇到 401 时共享同一次刷新,避免重复刷新导致令牌竞争。
const resp = await fetch(buildUrl('/api/user/refresh'), {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ refreshToken })
+ credentials: 'include',
+ cache: 'no-store'
})
const result = await resp.json().catch(() => null) as Result | null
- if (!resp.ok || !result || result.code !== 200 || !result.data) {
+ if (!resp.ok || !result || result.code !== 200 || !result.data?.accessToken) {
throw new Error(result?.message || '刷新令牌失败')
}
- const data = result.data as { accessToken: string; refreshToken: string }
- setTokens(data.accessToken, data.refreshToken)
+ const data = result.data as { accessToken: string }
+ setTokens(data.accessToken)
return data
})()
refreshInFlight = task
@@ -97,6 +97,10 @@ function isAuthErrorStatus(status: number, code?: number | null) {
return status === 401 || code === 401 || (status === 403 && code === 401)
}
+function canAutoRefresh(path: string) {
+ return !['/api/user/login', '/api/user/register', '/api/user/refresh'].includes(path)
+}
+
export async function request(path: string, options: RequestInit & { query?: Record } = {}) {
const doRequest = async (useToken: string): Promise<{ response: Response; result: Result | null }> => {
const headers = new Headers(options.headers)
@@ -108,6 +112,7 @@ export async function request(path: string, options: RequestInit & { query?:
}
const response = await fetch(buildUrl(path, options.query), {
...options,
+ credentials: 'include',
headers
})
const result = await response.json().catch(() => null) as Result | null
@@ -117,9 +122,9 @@ export async function request(path: string, options: RequestInit & { query?:
let token = getAccessToken()
let { response, result } = await doRequest(token)
- // 401 时尝试用 refreshToken 刷新一次(非破坏性)
+ // Access Token 失效后仅尝试一次 HttpOnly Cookie 刷新;refresh 自身不会递归刷新。
const isAuthError = isAuthErrorStatus(response.status, result?.code)
- if (isAuthError && getRefreshToken()) {
+ if (isAuthError && canAutoRefresh(path)) {
try {
await refreshAccessToken()
token = getAccessToken()
@@ -164,6 +169,7 @@ function uploadOnce(
const xhr = new XMLHttpRequest()
const abort = () => xhr.abort()
xhr.open('POST', buildUrl(path))
+ xhr.withCredentials = true
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = event => {
@@ -203,7 +209,7 @@ export async function uploadRequest(
let response = await uploadOnce(path, body, token, options)
const isAuthError = isAuthErrorStatus(response.status, response.result?.code)
- if (isAuthError && getRefreshToken() && !options.signal?.aborted) {
+ if (isAuthError && !options.signal?.aborted) {
try {
await refreshAccessToken()
token = getAccessToken()
From 5140e669f5d3085db10549430d5d1f28fe13a581 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:48:40 +0800
Subject: [PATCH 11/20] fix(frontend): restore sessions only through HttpOnly
refresh cookie
---
shiqian-frontend/src/stores/auth.ts | 48 ++++++++++++++++++++++++-----
1 file changed, 41 insertions(+), 7 deletions(-)
diff --git a/shiqian-frontend/src/stores/auth.ts b/shiqian-frontend/src/stores/auth.ts
index 6a5720c..84d9eef 100644
--- a/shiqian-frontend/src/stores/auth.ts
+++ b/shiqian-frontend/src/stores/auth.ts
@@ -19,11 +19,13 @@ async function resourceStore() {
export const useAuthStore = defineStore('auth', () => {
const role = ref((localStorage.getItem('shiqian_role') as Role) || 'student')
- const logged = ref(Boolean(localStorage.getItem('shiqian_access_token')))
+ const logged = ref(false)
+ const initialized = ref(false)
const currentUser = ref(null)
let currentUserLoadedAt = 0
let currentUserInFlight: Promise | null = null
+ let restoreInFlight: Promise | null = null
const DATA_CACHE_TTL_MS = 30_000
@@ -68,13 +70,43 @@ export const useAuthStore = defineStore('auth', () => {
return task
}
+ /**
+ * 新页面没有持久化 access token;启动时通过 HttpOnly refresh cookie 恢复一次会话。
+ * 多个路由守卫共享同一个恢复 Promise,避免 refresh token 轮换竞争。
+ */
+ async function restoreSession() {
+ if (initialized.value) return
+ if (restoreInFlight) return restoreInFlight
+
+ clearTokens() // 同时迁移清理旧版本 localStorage token。
+ const task = (async () => {
+ try {
+ await refreshAccessToken()
+ logged.value = true
+ await loadCurrentUser({ force: true })
+ } catch {
+ clearTokens()
+ await clearLocalSession()
+ } finally {
+ initialized.value = true
+ }
+ })()
+ restoreInFlight = task
+ try {
+ await task
+ } finally {
+ if (restoreInFlight === task) restoreInFlight = null
+ }
+ }
+
async function login(username: string, password: string) {
const data = await request('/api/user/login', {
method: 'POST',
body: jsonBody({ username, password })
})
- setTokens(data.accessToken, data.refreshToken)
+ setTokens(data.accessToken)
logged.value = true
+ initialized.value = true
currentUser.value = data
setRole(data.role === 'ADMIN' ? 'admin' : 'student')
currentUserLoadedAt = Date.now()
@@ -89,8 +121,8 @@ export const useAuthStore = defineStore('auth', () => {
}
async function refresh() {
- // 显式刷新(request 层已自动处理 401 场景,此为可选手动调用)
await refreshAccessToken()
+ logged.value = true
}
async function register(payload: RegisterPayload) {
@@ -102,13 +134,13 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout() {
- // 服务端撤销 access + 全部 refresh,避免本地清 token 后令牌仍可被盗用。
try {
await request('/api/user/logout', { method: 'POST' })
} catch {
- // 网络或已过期时仍清理本地态
+ // 网络或令牌已失效时仍清理本地内存态;服务端 Cookie 会在可达时清除。
}
clearTokens()
+ initialized.value = true
await clearLocalSession()
}
@@ -117,8 +149,9 @@ export const useAuthStore = defineStore('auth', () => {
method: 'PUT',
body: jsonBody(payload)
})
- // 改密后后端会使全部令牌失效,本地必须退出并要求重新登录。
+ // 改密后后端使全部令牌失效并清 refresh cookie,本地必须退出。
clearTokens()
+ initialized.value = true
await clearLocalSession()
}
@@ -127,7 +160,6 @@ export const useAuthStore = defineStore('auth', () => {
method: 'PUT',
body: jsonBody(payload)
})
- // 刷新当前用户信息
if (currentUser.value) {
Object.assign(currentUser.value, payload)
}
@@ -136,8 +168,10 @@ export const useAuthStore = defineStore('auth', () => {
return {
role,
logged,
+ initialized,
currentUser,
setRole,
+ restoreSession,
login,
register,
refresh,
From c999366ecc19977cd68144b114896c7fd33e3d0d Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:49:01 +0800
Subject: [PATCH 12/20] fix(frontend): restore cookie session before route
authorization
---
shiqian-frontend/src/router/index.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/shiqian-frontend/src/router/index.ts b/shiqian-frontend/src/router/index.ts
index f96dd3e..87c50f0 100644
--- a/shiqian-frontend/src/router/index.ts
+++ b/shiqian-frontend/src/router/index.ts
@@ -61,6 +61,12 @@ const router = createRouter({
router.beforeEach(async to => {
const auth = useAuthStore()
+
+ // Access Token 不再持久化。每个新页面只在首次导航时使用 HttpOnly Cookie 恢复会话。
+ if (!auth.initialized) {
+ await auth.restoreSession()
+ }
+
const requiresAuth = Boolean(to.meta.requiresAuth)
const roles = to.meta.roles as string[] | undefined
const isAdminRoute = to.path === '/admin' || to.path.startsWith('/admin/')
From db0e177b480346bec2fcefda53726c4d2cbbd35a Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:49:26 +0800
Subject: [PATCH 13/20] refactor(frontend): remove refresh token from login
contract
---
shiqian-frontend/src/stores/types.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/shiqian-frontend/src/stores/types.ts b/shiqian-frontend/src/stores/types.ts
index e7ef24c..0afcd59 100644
--- a/shiqian-frontend/src/stores/types.ts
+++ b/shiqian-frontend/src/stores/types.ts
@@ -215,7 +215,6 @@ export interface ContentReviewRecordItem {
export interface LoginResponse {
accessToken: string
- refreshToken: string
userId: number
username: string
nickname: string
From 411b65e92f6468d4bfc8dfe745637a5f9961f1d3 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:50:06 +0800
Subject: [PATCH 14/20] feat(gateway): add credential-safe exact-origin CORS
filter
---
.../shiqian/gateway/config/CorsConfig.java | 46 +++++++++++++++++++
1 file changed, 46 insertions(+)
create mode 100644 shiqian-gateway/src/main/java/com/shiqian/gateway/config/CorsConfig.java
diff --git a/shiqian-gateway/src/main/java/com/shiqian/gateway/config/CorsConfig.java b/shiqian-gateway/src/main/java/com/shiqian/gateway/config/CorsConfig.java
new file mode 100644
index 0000000..bc7032c
--- /dev/null
+++ b/shiqian-gateway/src/main/java/com/shiqian/gateway/config/CorsConfig.java
@@ -0,0 +1,46 @@
+package com.shiqian.gateway.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.reactive.CorsWebFilter;
+import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 网关统一处理浏览器跨域请求。
+ *
+ * 认证使用 HttpOnly Cookie,因此必须启用 credentials,同时禁止 wildcard origin。
+ */
+@Configuration
+public class CorsConfig {
+
+ @Bean
+ public CorsWebFilter corsWebFilter(
+ @Value("${campushub.cors.allowed-origins:http://localhost:5173,http://127.0.0.1:5173}")
+ String configuredOrigins) {
+ List allowedOrigins = Arrays.stream(configuredOrigins.split(","))
+ .map(String::trim)
+ .filter(value -> !value.isBlank())
+ .toList();
+
+ if (allowedOrigins.isEmpty() || allowedOrigins.contains("*")) {
+ throw new IllegalStateException("CORS_ALLOWED_ORIGINS 必须配置精确 Origin,且不能使用 *");
+ }
+
+ CorsConfiguration config = new CorsConfiguration();
+ config.setAllowedOrigins(allowedOrigins);
+ config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+ config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"));
+ config.setExposedHeaders(List.of("Content-Disposition"));
+ config.setAllowCredentials(true);
+ config.setMaxAge(3600L);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+ return new CorsWebFilter(source);
+ }
+}
From cab6e1a22f826ef576b4c511bf99c7e0a6defdf0 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:50:19 +0800
Subject: [PATCH 15/20] config(gateway): enable exact-origin credential CORS
and short access tokens
---
shiqian-gateway/src/main/resources/application.yml | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/shiqian-gateway/src/main/resources/application.yml b/shiqian-gateway/src/main/resources/application.yml
index f174e1e..18b2fbc 100644
--- a/shiqian-gateway/src/main/resources/application.yml
+++ b/shiqian-gateway/src/main/resources/application.yml
@@ -28,9 +28,14 @@ spring:
# 即梦同步仅允许直连 resource 本机端口,不经由公网网关暴露。
- Path=/api/resource/**,/api/category/**,/api/tag/**,/api/admin/**
+campushub:
+ cors:
+ # Cookie 认证必须使用精确 Origin;生产环境显式设置 CORS_ALLOWED_ORIGINS。
+ allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
+
jwt:
secret: ${JWT_SECRET}
- access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:7200000}
+ access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:1800000}
refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800000}
gateway:
From c5e104d33f4a68db997f75e435581758aabf3fbc Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:51:29 +0800
Subject: [PATCH 16/20] docs(security): document HttpOnly cookie and credential
CORS deployment
---
docs/deployment/github-pages-caddy-api.md | 284 +++++++++-------------
1 file changed, 114 insertions(+), 170 deletions(-)
diff --git a/docs/deployment/github-pages-caddy-api.md b/docs/deployment/github-pages-caddy-api.md
index 6589058..17441c7 100644
--- a/docs/deployment/github-pages-caddy-api.md
+++ b/docs/deployment/github-pages-caddy-api.md
@@ -1,223 +1,170 @@
# GitHub Pages 前端 + Caddy 后端部署
-本文只针对本项目 `Java006-CampusHub` 的前后端分离部署:
+本文针对 `Java-006-CampusHub` 的前后端分离部署:
-- 前端:GitHub Actions 构建 Vue 项目,并部署到 GitHub Pages
-- 后端:运行在你的服务器上
-- 服务器入口:Caddy + 后端 API 域名
-- 后端网关:`shiqian-gateway`,本机端口 `8080`
+- 前端:GitHub Pages
+- API:`https://api.xiaoqianran.xyz`
+- Caddy:TLS + 反向代理
+- API 网关:`shiqian-gateway`(本机 `8080`)
-## 推荐域名结构
+> 认证安全模型已经调整:Access Token 仅保存在页面内存,Refresh Token 仅保存在 `HttpOnly` Cookie。浏览器请求 API 必须携带 credentials,并由 Gateway 统一处理精确 Origin CORS。
-建议用一个独立 API 子域名给后端:
+## 推荐域名结构
| 用途 | 示例 |
-|------|------|
-| GitHub Pages 前端 | `https://<你的GitHub用户名>.github.io/Java006-CampusHub/` |
-| GitHub Pages 自定义前端域名 | `https://shiqian.xiaoqianran.xyz` |
+|---|---|
+| GitHub Pages 默认域名 | `https://.github.io/Java-006-CampusHub/` |
+| 自定义前端域名 | `https://shiqian.xiaoqianran.xyz` |
| 后端 API 域名 | `https://api.xiaoqianran.xyz` |
-前端所有接口请求都访问:
-
-```text
-https://api.xiaoqianran.xyz/api/...
-```
-
-后端服务器上,Caddy 只需要把 `api.xiaoqianran.xyz` 反代到网关:
+前端 API 地址:
```text
-127.0.0.1:8080
+https://api.xiaoqianran.xyz
```
## 服务端口
-| 服务 | 端口 | 是否建议公网暴露 | 说明 |
-|------|------|------------------|------|
-| `shiqian-gateway` | `8080` | 是,仅通过 Caddy 暴露 | 前端所有 `/api/*` 请求进入这里 |
-| `shiqian-user` | `8081` | 否 | 网关内部转发用户接口 |
-| `shiqian-resource` | `8082` | 否 | 网关内部转发资源和分类接口 |
-| MySQL | `3306` | 否 | 数据库 |
-| Redis | `6379` | 否 | 缓存 |
-| Nacos | `8848` | 否 | 服务发现/配置 |
-| Elasticsearch | `9200` | 否 | 搜索 |
-| RabbitMQ | `5672` / `15672` | 否 | 消息队列和管理页 |
-
-## 服务器 Caddyfile
-
-下面是后端 API 域名的 Caddy 配置。把 `Access-Control-Allow-Origin` 改成你的 GitHub Pages 前端地址。
+| 服务 | 端口 | 公网暴露 |
+|---|---:|---|
+| `shiqian-gateway` | 8080 | 仅通过 Caddy |
+| `shiqian-user` | 8081 | 否 |
+| `shiqian-resource` | 8082 | 否 |
+| MySQL | 3306 | 否 |
+| Redis | 6379 | 否 |
+| Nacos | 8848 | 否 |
+| Elasticsearch | 9200 | 否 |
+| RabbitMQ | 5672 / 15672 | 否 |
-如果你使用 GitHub Pages 默认地址:
+## Caddyfile
-```text
-https://<你的GitHub用户名>.github.io
-```
-
-如果你给 GitHub Pages 绑定了自定义域名,例如:
-
-```text
-https://shiqian.xiaoqianran.xyz
-```
-
-就填这个自定义域名。
+CORS 已由 `shiqian-gateway` 统一处理,Caddy **不要再次写 `Access-Control-*` 响应头**,否则容易产生重复/冲突头。
```caddyfile
-# ====================== shiqian api ======================
api.xiaoqianran.xyz {
encode gzip zstd
-
- @preflight method OPTIONS
- respond @preflight 204
-
- header {
- Access-Control-Allow-Origin "https://<你的前端Pages域名>"
- Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS"
- Access-Control-Allow-Headers "Authorization,Content-Type"
- Access-Control-Max-Age "86400"
- Vary "Origin"
- }
-
reverse_proxy 127.0.0.1:8080
}
```
-示例:如果前端是 GitHub Pages 默认地址:
-
-```caddyfile
-api.xiaoqianran.xyz {
- encode gzip zstd
-
- @preflight method OPTIONS
- respond @preflight 204
-
- header {
- Access-Control-Allow-Origin "https://你的GitHub用户名.github.io"
- Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS"
- Access-Control-Allow-Headers "Authorization,Content-Type"
- Access-Control-Max-Age "86400"
- Vary "Origin"
- }
+重载:
- reverse_proxy 127.0.0.1:8080
-}
+```bash
+caddy reload --config /etc/caddy/Caddyfile
```
-示例:如果前端 GitHub Pages 绑定自定义域名 `shiqian.xiaoqianran.xyz`:
+## 认证 Cookie 与 Origin 配置
-```caddyfile
-api.xiaoqianran.xyz {
- encode gzip zstd
-
- @preflight method OPTIONS
- respond @preflight 204
+### 场景 A:GitHub Pages 默认域名
- header {
- Access-Control-Allow-Origin "https://shiqian.xiaoqianran.xyz"
- Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS"
- Access-Control-Allow-Headers "Authorization,Content-Type"
- Access-Control-Max-Age "86400"
- Vary "Origin"
- }
+例如:
- reverse_proxy 127.0.0.1:8080
-}
+```text
+Frontend: https://xiaoqianran.github.io
+API: https://api.xiaoqianran.xyz
```
-重载 Caddy:
+两者属于跨站点。生产环境:
```bash
-caddy reload --config /etc/caddy/Caddyfile
+CORS_ALLOWED_ORIGINS=https://xiaoqianran.github.io
+BROWSER_AUTH_ALLOWED_ORIGINS=https://xiaoqianran.github.io
+REFRESH_TOKEN_COOKIE_SECURE=true
+REFRESH_TOKEN_COOKIE_SAME_SITE=None
```
-## GitHub Actions Pages 配置
+`SameSite=None` 必须和 `Secure=true` 一起使用。
-仓库已经有前端 Pages workflow:
+### 场景 B:自定义同站前端子域
+
+例如:
```text
-.github/workflows/deploy-frontend-pages.yml
+Frontend: https://shiqian.xiaoqianran.xyz
+API: https://api.xiaoqianran.xyz
```
-### 当前 workflow 主要改进(2026 年更新)
+推荐:
-- 使用 `actions/configure-pages@v4` 初始化 Pages 环境(官方推荐)
-- 自动生成 `.nojekyll` 文件,避免 GitHub Pages 的 Jekyll 处理导致的资源 404 问题
-- `VITE_BASE` 使用 `github.event.repository.name`(GitHub Actions 表达式支持的稳定写法)
-- 构建时优先读取仓库 Variables 中的 `VITE_API_BASE_URL`
+```bash
+CORS_ALLOWED_ORIGINS=https://shiqian.xiaoqianran.xyz
+BROWSER_AUTH_ALLOWED_ORIGINS=https://shiqian.xiaoqianran.xyz
+REFRESH_TOKEN_COOKIE_SECURE=true
+REFRESH_TOKEN_COOKIE_SAME_SITE=Lax
+```
-### 配置步骤
+如果需要同时允许多个前端 Origin,使用英文逗号分隔:
-1. 进入仓库 `Settings -> Secrets and variables -> Actions -> Variables`,新增仓库变量:
+```bash
+CORS_ALLOWED_ORIGINS=https://xiaoqianran.github.io,https://shiqian.xiaoqianran.xyz
+BROWSER_AUTH_ALLOWED_ORIGINS=https://xiaoqianran.github.io,https://shiqian.xiaoqianran.xyz
+```
- **变量名**:`VITE_API_BASE_URL`
-
- **变量值**:`https://你的真实API域名`(例如 `https://api.xiaoqianran.xyz`)
+禁止配置:
- > **重要**:不配置时会回退到示例域名,可能导致前端无法与你的后端交互(出现“系统内部错误”等)。
+```text
+CORS_ALLOWED_ORIGINS=*
+```
-2. 进入 `Settings -> Pages`,将 `Build and deployment` 的 `Source` 设置为 **GitHub Actions**。
+因为本项目启用了 credential cookie,`*` 既不安全,也与 credential CORS 语义冲突。
-3. 推送到 `main` 分支或手动触发 `Deploy Frontend to GitHub Pages` workflow 即可自动部署。
+## JWT 建议
-workflow 现在更贴近 GitHub 官方 Pages 部署最佳实践,部署成功率和稳定性显著提升。
+生产环境至少显式配置:
-## GitHub Pages 路径说明
+```bash
+JWT_SECRET=<高熵随机密钥,至少 32 字节>
+JWT_ACCESS_TOKEN_EXPIRATION=1800000
+JWT_REFRESH_TOKEN_EXPIRATION=604800000
+```
-当前 `deploy-frontend-pages.yml` workflow 使用以下表达式(GitHub Actions 官方支持的写法):
+默认 Access Token 为 30 分钟,Refresh Token 为 7 天。Refresh Token 不会出现在登录/刷新 JSON 中,也不会进入 `localStorage`。
-```yaml
-VITE_BASE: /${{ github.event.repository.name }}/
-```
+## GitHub Pages 配置
-这适合 GitHub Pages 默认项目地址,兼容 push 和手动触发:
+仓库使用:
```text
-https://<你的GitHub用户名>.github.io/Java006-CampusHub/
+.github/workflows/deploy-frontend-pages.yml
```
-如果你给 Pages 绑定了自定义域名,并且网站在域名根路径访问,例如:
+在:
```text
-https://shiqian.xiaoqianran.xyz/
+Settings -> Secrets and variables -> Actions -> Variables
```
-可以手动把 workflow 里的 `VITE_BASE` 改成:
+设置:
-```yaml
-VITE_BASE: /
+```text
+VITE_API_BASE_URL=https://api.xiaoqianran.xyz
```
-否则静态资源路径会多一层仓库名。
-
-**注意**:workflow 内部已自动处理,无需在大多数场景下手动修改。注意 GitHub Actions 表达式语法有限,不支持 `.split()` 等 JS 方法。
-
-## 前端如何指定后端地址
-
-本项目前端支持两种后端地址配置。
-
-### 方式一:GitHub Actions 变量,推荐用于 Pages
-
-在 GitHub Actions Variables 中设置:
+然后在:
```text
-VITE_API_BASE_URL=https://api.xiaoqianran.xyz
+Settings -> Pages
```
-然后重新运行 `Deploy Frontend to GitHub Pages` workflow。
+选择 GitHub Actions 作为部署源。
-### 方式二:运行时 config.js
+## Pages 路径
-本地或自托管静态文件时,可以编辑:
+默认项目 Pages 地址使用:
-```text
-shiqian-frontend/public/config.js
+```yaml
+VITE_BASE: /${{ github.event.repository.name }}/
```
-或构建后的:
+如果绑定自定义域名并从域名根目录访问,可将构建路径设置为:
-```text
-shiqian-frontend/dist/config.js
+```yaml
+VITE_BASE: /
```
-示例:
+## 运行时 API 地址
+
+前端也支持:
```js
window.__SHIQIAN_CONFIG__ = {
@@ -225,43 +172,40 @@ window.__SHIQIAN_CONFIG__ = {
}
```
-注意:GitHub Pages 上的 `config.js` 来自仓库构建产物,不能直接登录服务器修改;Pages 场景优先使用 GitHub Actions Variables。
+对应:
-## 前端本地开发代理
+```text
+shiqian-frontend/public/config.js
+```
-本地开发默认代理到:
+Pages 场景优先使用 Actions Variables。
+
+## 本地开发
+
+默认 Vite 代理目标:
```text
http://localhost:8080
```
-如果本地前端要连服务器后端:
+也可指定:
```bash
cd shiqian-frontend
VITE_API_PROXY_TARGET=https://api.xiaoqianran.xyz npm run dev -- --host 0.0.0.0
```
-本地开发时,浏览器请求 `/api/...`,Vite 开发服务器会把请求代理到 `VITE_API_PROXY_TARGET`。
-
-## 部署检查清单
-
-1. 后端服务器启动 `shiqian-gateway`,确认监听 `8080`。
-2. DNS 添加 `api.xiaoqianran.xyz`,指向你的服务器公网 IP。
-3. Caddy 使用上面的 `api.xiaoqianran.xyz` 配置(正确设置 CORS)。
-4. Caddy 的 `Access-Control-Allow-Origin` 填你的 GitHub Pages 前端域名(或使用 `*` 临时测试)。
-5. **GitHub 仓库设置**:
- - `Settings -> Pages` → Source 选择 **GitHub Actions**
- - `Settings -> Secrets and variables -> Actions -> Variables` 新增 `VITE_API_BASE_URL`(**强烈建议配置**)
-6. 推送代码到 `main` 或手动触发 `Deploy Frontend to GitHub Pages` workflow。
-7. 部署成功后,在浏览器开发者工具 Network 面板确认接口请求正确发往你的后端域名。
-
-## 本项目已改动的前端文件
-
-| 文件 | 作用 |
-|------|------|
-| `shiqian-frontend/public/config.js` | 运行时后端地址配置,Pages 场景通常保持空值并使用 GitHub Actions Variables |
-| `shiqian-frontend/index.html` | 加载 `/config.js` |
-| `shiqian-frontend/src/api/client.ts` | 优先读取运行时配置,其次读取 `VITE_API_BASE_URL` |
-| `shiqian-frontend/src/env.d.ts` | 声明 `window.__SHIQIAN_CONFIG__` 类型 |
-| `shiqian-frontend/vite.config.ts` | 开发代理支持 `VITE_API_PROXY_TARGET` |
+本地 `shiqian-user` profile 会把 Refresh Cookie 的 `Secure` 关闭,以允许 `http://localhost` 开发;不要把 local profile 用于生产。
+
+## 上线检查清单
+
+1. `shiqian-user`、`shiqian-resource`、数据库、Redis、Nacos 等内部端口不对公网开放。
+2. Caddy 只反向代理到 Gateway `127.0.0.1:8080`。
+3. `JWT_SECRET`、`INTERNAL_SERVICE_KEY` 使用独立高熵随机值。
+4. `CORS_ALLOWED_ORIGINS` 与 `BROWSER_AUTH_ALLOWED_ORIGINS` 都是精确 HTTPS Origin,不能是 `*`。
+5. GitHub Pages 默认域名部署使用 `REFRESH_TOKEN_COOKIE_SAME_SITE=None` + `REFRESH_TOKEN_COOKIE_SECURE=true`。
+6. 自定义同站子域优先使用 `SameSite=Lax` + `Secure=true`。
+7. 浏览器 Network 中登录响应应出现 `Set-Cookie: campushub_refresh=...; HttpOnly; Secure; ...`。
+8. 登录/刷新响应 JSON 中不得出现 `refreshToken`。
+9. Application -> Local Storage 中不得出现 `shiqian_access_token` 或 `shiqian_refresh_token`。
+10. 刷新页面后,应通过一次 `/api/user/refresh` Cookie 请求恢复登录,而不是从 Web Storage 恢复 token。
From 21e32c2ba6c5a29f32d743eeee41a2670ff72318 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:51:47 +0800
Subject: [PATCH 17/20] test(auth): cover HttpOnly refresh cookie contract
---
.../RefreshTokenCookieServiceTest.java | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 shiqian-user/src/test/java/com/shiqian/user/security/RefreshTokenCookieServiceTest.java
diff --git a/shiqian-user/src/test/java/com/shiqian/user/security/RefreshTokenCookieServiceTest.java b/shiqian-user/src/test/java/com/shiqian/user/security/RefreshTokenCookieServiceTest.java
new file mode 100644
index 0000000..8c6b9f8
--- /dev/null
+++ b/shiqian-user/src/test/java/com/shiqian/user/security/RefreshTokenCookieServiceTest.java
@@ -0,0 +1,45 @@
+package com.shiqian.user.security;
+
+import jakarta.servlet.http.Cookie;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RefreshTokenCookieServiceTest {
+
+ @Test
+ void shouldWriteHttpOnlySecureCookieWithoutExposingTokenToJavascript() {
+ RefreshTokenCookieService service = new RefreshTokenCookieService(
+ "campushub_refresh", true, "None", "", 604800000L);
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ service.write(response, "refresh-token-value");
+
+ String setCookie = response.getHeader("Set-Cookie");
+ assertTrue(setCookie.contains("campushub_refresh=refresh-token-value"));
+ assertTrue(setCookie.contains("HttpOnly"));
+ assertTrue(setCookie.contains("Secure"));
+ assertTrue(setCookie.contains("SameSite=None"));
+ assertTrue(setCookie.contains("Path=/api/user"));
+ }
+
+ @Test
+ void shouldReadAndClearRefreshCookie() {
+ RefreshTokenCookieService service = new RefreshTokenCookieService(
+ "campushub_refresh", false, "Lax", "", 604800000L);
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setCookies(new Cookie("other", "x"), new Cookie("campushub_refresh", "token-123"));
+
+ assertEquals("token-123", service.read(request));
+
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ service.clear(response);
+ String setCookie = response.getHeader("Set-Cookie");
+ assertTrue(setCookie.contains("campushub_refresh="));
+ assertTrue(setCookie.contains("Max-Age=0"));
+ assertTrue(setCookie.contains("HttpOnly"));
+ }
+}
From 6c794ca91bc5a98a9109e78005ab39dd7a0a9387 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:51:59 +0800
Subject: [PATCH 18/20] test(auth): reject untrusted browser origins
---
.../filter/BrowserAuthOriginFilterTest.java | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 shiqian-user/src/test/java/com/shiqian/user/filter/BrowserAuthOriginFilterTest.java
diff --git a/shiqian-user/src/test/java/com/shiqian/user/filter/BrowserAuthOriginFilterTest.java b/shiqian-user/src/test/java/com/shiqian/user/filter/BrowserAuthOriginFilterTest.java
new file mode 100644
index 0000000..a2d9ed6
--- /dev/null
+++ b/shiqian-user/src/test/java/com/shiqian/user/filter/BrowserAuthOriginFilterTest.java
@@ -0,0 +1,39 @@
+package com.shiqian.user.filter;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class BrowserAuthOriginFilterTest {
+
+ private final BrowserAuthOriginFilter filter = new BrowserAuthOriginFilter(
+ "https://frontend.example.com,http://localhost:5173");
+
+ @Test
+ void shouldRejectUntrustedBrowserOriginOnRefresh() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/user/refresh");
+ request.addHeader("Origin", "https://evil.example.com");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(request, response, new MockFilterChain());
+
+ assertEquals(403, response.getStatus());
+ }
+
+ @Test
+ void shouldAllowTrustedOriginAndNonBrowserClient() throws Exception {
+ MockHttpServletRequest trusted = new MockHttpServletRequest("POST", "/api/user/login");
+ trusted.addHeader("Origin", "https://frontend.example.com");
+ MockHttpServletResponse trustedResponse = new MockHttpServletResponse();
+ filter.doFilter(trusted, trustedResponse, new MockFilterChain());
+ assertEquals(200, trustedResponse.getStatus());
+
+ MockHttpServletRequest noOrigin = new MockHttpServletRequest("POST", "/api/user/refresh");
+ MockHttpServletResponse noOriginResponse = new MockHttpServletResponse();
+ filter.doFilter(noOrigin, noOriginResponse, new MockFilterChain());
+ assertEquals(200, noOriginResponse.getStatus());
+ }
+}
From 1f0f384d79dfa76a6564f80e8c055cff52a33a90 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:52:11 +0800
Subject: [PATCH 19/20] test(security): prove all internal paths require
service key
---
.../filter/InternalServiceKeyFilterTest.java | 47 +++++++++++++++++++
1 file changed, 47 insertions(+)
create mode 100644 shiqian-user/src/test/java/com/shiqian/user/filter/InternalServiceKeyFilterTest.java
diff --git a/shiqian-user/src/test/java/com/shiqian/user/filter/InternalServiceKeyFilterTest.java b/shiqian-user/src/test/java/com/shiqian/user/filter/InternalServiceKeyFilterTest.java
new file mode 100644
index 0000000..6f81b37
--- /dev/null
+++ b/shiqian-user/src/test/java/com/shiqian/user/filter/InternalServiceKeyFilterTest.java
@@ -0,0 +1,47 @@
+package com.shiqian.user.filter;
+
+import com.shiqian.common.user.InternalApiHeaders;
+import com.shiqian.user.security.InternalServiceKeyValidator;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class InternalServiceKeyFilterTest {
+
+ private final InternalServiceKeyFilter filter = new InternalServiceKeyFilter(
+ new InternalServiceKeyValidator("test-internal-key"));
+
+ @Test
+ void shouldProtectFutureInternalEndpointsWithoutControllerChanges() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/internal/future-endpoint");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(request, response, new MockFilterChain());
+
+ assertEquals(403, response.getStatus());
+ }
+
+ @Test
+ void shouldAllowInternalRequestWithCorrectServiceKey() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/internal/future-endpoint");
+ request.addHeader(InternalApiHeaders.SERVICE_KEY, "test-internal-key");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(request, response, new MockFilterChain());
+
+ assertEquals(200, response.getStatus());
+ }
+
+ @Test
+ void shouldNotAffectPublicApiPaths() throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/user/health");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(request, response, new MockFilterChain());
+
+ assertEquals(200, response.getStatus());
+ }
+}
From a2d88b3aaad6aa088edd8ecb801240c7c7878626 Mon Sep 17 00:00:00 2001
From: xiaoqianran <1416220530@qq.com>
Date: Tue, 18 Aug 2026 18:55:13 +0800
Subject: [PATCH 20/20] test(frontend): assert tokens stay out of Web Storage
---
shiqian-frontend/src/api/client.test.ts | 28 ++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/shiqian-frontend/src/api/client.test.ts b/shiqian-frontend/src/api/client.test.ts
index 2ceb3dd..bacb4ba 100644
--- a/shiqian-frontend/src/api/client.test.ts
+++ b/shiqian-frontend/src/api/client.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearTokens,
+ getAccessToken,
request,
setAuthFailureHandler,
setTokens
@@ -9,17 +10,19 @@ import {
describe('api client auth handling', () => {
beforeEach(() => {
localStorage.clear()
+ clearTokens()
setAuthFailureHandler(null)
vi.restoreAllMocks()
})
afterEach(() => {
+ clearTokens()
localStorage.clear()
setAuthFailureHandler(null)
})
- it('refreshes on 401 then retries once', async () => {
- setTokens('old-access', 'refresh-token')
+ it('refreshes on 401 then retries once without persisting tokens to Web Storage', async () => {
+ setTokens('old-access')
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: false,
@@ -32,7 +35,7 @@ describe('api client auth handling', () => {
json: async () => ({
code: 200,
message: 'ok',
- data: { accessToken: 'new-access', refreshToken: 'new-refresh' }
+ data: { accessToken: 'new-access' }
})
})
.mockResolvedValueOnce({
@@ -44,12 +47,19 @@ describe('api client auth handling', () => {
const data = await request<{ id: number }>('/api/resource/mine')
expect(data).toEqual({ id: 1 })
- expect(localStorage.getItem('shiqian_access_token')).toBe('new-access')
+ expect(getAccessToken()).toBe('new-access')
+ expect(localStorage.getItem('shiqian_access_token')).toBeNull()
+ expect(localStorage.getItem('shiqian_refresh_token')).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(3)
+ expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
+ method: 'POST',
+ credentials: 'include',
+ cache: 'no-store'
+ })
})
it('clears session when refresh fails after auth error', async () => {
- setTokens('old-access', 'refresh-token')
+ setTokens('old-access')
const onFail = vi.fn()
setAuthFailureHandler(onFail)
const fetchMock = vi.fn()
@@ -71,11 +81,13 @@ describe('api client auth handling', () => {
await expect(request('/api/resource/mine')).rejects.toThrow('登录已过期')
expect(onFail).toHaveBeenCalled()
+ expect(getAccessToken()).toBe('')
expect(localStorage.getItem('shiqian_access_token')).toBeNull()
+ expect(localStorage.getItem('shiqian_refresh_token')).toBeNull()
})
it('treats 403 with business code 401 as auth error', async () => {
- setTokens('old-access', 'refresh-token')
+ setTokens('old-access')
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: false,
@@ -88,7 +100,7 @@ describe('api client auth handling', () => {
json: async () => ({
code: 200,
message: 'ok',
- data: { accessToken: 'a2', refreshToken: 'r2' }
+ data: { accessToken: 'a2' }
})
})
.mockResolvedValueOnce({
@@ -99,5 +111,7 @@ describe('api client auth handling', () => {
vi.stubGlobal('fetch', fetchMock)
await expect(request('/api/resource/favorites')).resolves.toBe('ok')
+ expect(getAccessToken()).toBe('a2')
+ expect(localStorage.getItem('shiqian_access_token')).toBeNull()
})
})