JwtInterceptor.java

package org.darkroomlibrary.interceptor;

import org.darkroomlibrary.context.CurrentUserContext;
import org.darkroomlibrary.infrastructure.security.UserAuthLookup;
import org.darkroomlibrary.web.response.ApiResponse;
import org.darkroomlibrary.utils.JwtUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.Writer;

/**
 * token拦截器,做请求拦截
 */
public class JwtInterceptor implements HandlerInterceptor {

    private final String apiPrefix;
    private final UserAuthLookup userAuthLookup;
    private final ObjectMapper objectMapper;
    private final JwtUtil jwtUtil;

    public JwtInterceptor(
            String apiPrefix,
            UserAuthLookup userAuthLookup,
            ObjectMapper objectMapper,
            JwtUtil jwtUtil) {
        this.apiPrefix = apiPrefix;
        this.userAuthLookup = userAuthLookup;
        this.objectMapper = objectMapper;
        this.jwtUtil = jwtUtil;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestMethod = request.getMethod();
        // 放行预检请求
        if ("OPTIONS".equals(requestMethod)) {
            return true;
        }
        String requestURI = request.getRequestURI();
        // 白名单路径:使用精确前缀匹配替代 contains 子串匹配(防止绕过)
        if (matchesPath(requestURI, apiPrefix + "/user/login")
                || matchesPath(requestURI, apiPrefix + "/user/register")
                || matchesPath(requestURI, apiPrefix + "/user/resetPwd")
                || matchesPath(requestURI, apiPrefix + "/user/sendVerifyCode")
                || matchesPath(requestURI, apiPrefix + "/file/getFile")
                || matchesPath(requestURI, apiPrefix + "/file/public")
                || matchesPath(requestURI, apiPrefix + "/captcha/generate")
                || matchesPath(requestURI, apiPrefix + "/captcha/verify")
                || matchesPath(requestURI, apiPrefix + "/health/live")
                || matchesPath(requestURI, apiPrefix + "/health/ready")
                || matchesPath(requestURI, apiPrefix + "/error")) {
            return true;
        }
        Claims claims = jwtUtil.fromToken(resolveToken(request));
        if (claims == null) {
            writeAuthError(response);
            return false;
        }
        Integer userId = claims.get("id", Integer.class);
        Integer tokenAuthVersion = claims.get("authVersion", Integer.class);
        UserAuthLookup.AuthUser user = userAuthLookup.getActiveUser(userId).orElse(null);
        if (user == null
                || Boolean.TRUE.equals(user.getDisabled())
                || tokenAuthVersion == null
                || !tokenAuthVersion.equals(normalizeAuthVersion(user.getAuthVersion()))) {
            writeAuthError(response);
            return false;
        }
        CurrentUserContext.bind(user.getId(), user.getUserRole());
        return true;
    }

    private String resolveToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
            return authorization.substring(7).trim();
        }
        return request.getHeader("token");
    }

    private Integer normalizeAuthVersion(Integer authVersion) {
        return authVersion == null ? 1 : authVersion;
    }

    private boolean matchesPath(String requestURI, String path) {
        return requestURI.equals(path) || requestURI.startsWith(path + "/");
    }

    private void writeAuthError(HttpServletResponse response) throws Exception {
        ApiResponse<String> error = ApiResponse.error("身份认证异常,请先登录");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        Writer stream = response.getWriter();
        stream.write(objectMapper.writeValueAsString(error));
        stream.flush();
        stream.close();
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        CurrentUserContext.clear();
    }
}