轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

115 lines
5.1 KiB

package com.mydreamjob.deletion;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.security.CurrentUserAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Set;
/**
* 注销守卫拦截器。
*
* <p>基于当前已认证用户的账号注销状态拦截业务请求:
* <ul>
* <li>COMPLETED:除注销接口与 OPTIONS 外,所有普通业务接口一律返回 409/9303(旧 JWT 即使未过期也被拒绝)</li>
* <li>PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行</li>
* </ul>
* 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。
* 未认证、未注销用户不受影响。</p>
*
* <p><strong>身份解析复用唯一的 {@link CurrentUserAccessor}</strong>(R01-A):本拦截器注册在
* {@code /**},先于所有 Controller 执行,因此绝不能自己解析主体。此前它用
* {@code Character::isDigit} 判断后直接 {@code Long.valueOf(name)},导致 Long 溢出主体
* (如 {@code "99999999999999999999"})抛出 {@code NumberFormatException},在到达任何
* Controller 之前就逃逸为全站 HTTP 500。现在非法主体一律视为未认证并放行,
* 由 Controller 统一返回 401。</p>
*/
@Component
@RequiredArgsConstructor
public class DeletionGuardInterceptor implements HandlerInterceptor {
private static final Set<String> DELETION_PATHS = Set.of(
"/profile/me/deletion",
"/profile/me/deletion/cancel");
private static final Set<String> WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private final ObjectProvider<AccountDeletionService> deletionServiceProvider;
private final CurrentUserAccessor currentUserAccessor;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 未认证 / 匿名 / 非法或溢出主体 → 一律放行,交由 Controller 按未登录处理(401)。
Long userId = resolveUserIdOrNull(SecurityContextHolder.getContext().getAuthentication());
if (userId == null) {
return true;
}
// 切片测试(@WebMvcTest)等场景可能无此服务 Bean,此时不拦截
AccountDeletionService deletionService = deletionServiceProvider.getIfAvailable();
if (deletionService == null) {
return true;
}
String path = resolvePath(request);
if (DELETION_PATHS.contains(path)) {
return true;
}
DeletionStatus status = deletionService.effectiveStatus(userId);
if (status == DeletionStatus.COMPLETED) {
return reject(response, ErrorCode.ACCOUNT_DELETED, request); // 409/9303
}
if (status == DeletionStatus.PENDING && WRITE_METHODS.contains(request.getMethod().toUpperCase())) {
return reject(response, ErrorCode.ACCOUNT_DELETION_PENDING, request); // 409/9302
}
return true;
}
/**
* 解析用户 ID;任何非法主体返回 {@code null},绝不抛出。
*
* <p>该安全依赖由构造器强制注入;装配缺失时应用必须失败启动。</p>
*/
private Long resolveUserIdOrNull(Authentication authentication) {
try {
return currentUserAccessor.requireUserId(authentication);
} catch (InsufficientAuthenticationException e) {
return null;
}
}
private boolean reject(HttpServletResponse response, ErrorCode errorCode,
HttpServletRequest request) throws Exception {
response.setStatus(409);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String requestId = request.getHeader("X-Request-Id");
response.getWriter().write(
objectMapper.writeValueAsString(ApiResponse.error(errorCode.getCode(), errorCode.getMessage(), requestId)));
return false;
}
/** 解析去除 context-path 的请求路径,兼容真实服务器与 MockMvc。 */
private String resolvePath(HttpServletRequest request) {
String uri = request.getRequestURI();
String ctx = request.getContextPath();
if (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) {
return uri.substring(ctx.length());
}
return uri;
}
}