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() }) })