forked from baskbuddy/zhangshun
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
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;
|
|
}
|
|
}
|
|
|