|
|
@@ -0,0 +1,132 @@
|
|
|
+package com.malk.filter;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.malk.config.AuthConfigProperties;
|
|
|
+import com.malk.core.NonceCache;
|
|
|
+import com.malk.server.common.McR;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.slf4j.MDC;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.core.Ordered;
|
|
|
+import org.springframework.core.annotation.Order;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import org.springframework.util.AntPathMatcher;
|
|
|
+import org.springframework.web.filter.OncePerRequestFilter;
|
|
|
+
|
|
|
+import javax.servlet.FilterChain;
|
|
|
+import javax.servlet.ServletException;
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import javax.servlet.http.HttpServletResponse;
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 请求鉴权过滤器(capability: request-auth + replay-guard 第一阶段)
|
|
|
+ *
|
|
|
+ * <p>执行顺序位于 {@link TraceIdFilter} 之后:</p>
|
|
|
+ * <ol>
|
|
|
+ * <li>{@code enabled=false} → 直接放行</li>
|
|
|
+ * <li>路径在 {@code exempt-paths} → 放行</li>
|
|
|
+ * <li>校验 Header 完整性 → 缺失 401 {@code AUTH_HEADER_MISSING}</li>
|
|
|
+ * <li>时间戳窗口校验 → 超窗 401 {@code AUTH_TIMESTAMP_OUT_OF_WINDOW}</li>
|
|
|
+ * <li>Nonce 去重 → 重放 401 {@code AUTH_NONCE_REPLAYED}</li>
|
|
|
+ * <li>以上通过 → 写 MDC(authKey),交给 {@link AuthInterceptor} 做签名校验</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>注意:签名校验不在此处做,因为 Filter 阶段无法拿到 {@code HandlerMethod} 识别
|
|
|
+ * {@link NoAuth} 注解。签名校验延后到 Spring MVC {@code HandlerInterceptor.preHandle}。</p>
|
|
|
+ */
|
|
|
+@Component
|
|
|
+@Order(Ordered.HIGHEST_PRECEDENCE + 20)
|
|
|
+public class AuthFilter extends OncePerRequestFilter {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger("point");
|
|
|
+
|
|
|
+ public static final String H_KEY = "X-MJ-Key";
|
|
|
+ public static final String H_TS = "X-MJ-Timestamp";
|
|
|
+ public static final String H_NONCE = "X-MJ-Nonce";
|
|
|
+ public static final String H_SIG = "X-MJ-Signature";
|
|
|
+ public static final String MDC_AUTH_KEY = "authKey";
|
|
|
+
|
|
|
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private AuthConfigProperties props;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private NonceCache nonceCache;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ protected void doFilterInternal(HttpServletRequest request,
|
|
|
+ HttpServletResponse response,
|
|
|
+ FilterChain chain) throws ServletException, IOException {
|
|
|
+ if (!props.isEnabled()) {
|
|
|
+ chain.doFilter(request, response);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (isExempt(request.getServletPath())) {
|
|
|
+ chain.doFilter(request, response);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ String key = request.getHeader(H_KEY);
|
|
|
+ String ts = request.getHeader(H_TS);
|
|
|
+ String nonce = request.getHeader(H_NONCE);
|
|
|
+ String sig = request.getHeader(H_SIG);
|
|
|
+ if (empty(key) || empty(ts) || empty(nonce) || empty(sig)) {
|
|
|
+ write(response, 401, "AUTH_HEADER_MISSING");
|
|
|
+ log.warn("[Auth] header missing path={} key={}", request.getServletPath(), key);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ long clientMs;
|
|
|
+ try {
|
|
|
+ clientMs = Long.parseLong(ts);
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ write(response, 401, "AUTH_TIMESTAMP_OUT_OF_WINDOW");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ long nowMs = System.currentTimeMillis();
|
|
|
+ long windowMs = props.getWindow() * 1000L;
|
|
|
+ if (Math.abs(nowMs - clientMs) > windowMs) {
|
|
|
+ write(response, 401, "AUTH_TIMESTAMP_OUT_OF_WINDOW");
|
|
|
+ log.warn("[Auth] timestamp out of window path={} key={} client={} server={}",
|
|
|
+ request.getServletPath(), key, clientMs, nowMs);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!nonceCache.putIfAbsent(nonce)) {
|
|
|
+ write(response, 401, "AUTH_NONCE_REPLAYED");
|
|
|
+ log.warn("[Auth] nonce replayed path={} key={} nonce={}", request.getServletPath(), key, nonce);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ MDC.put(MDC_AUTH_KEY, key);
|
|
|
+ try {
|
|
|
+ chain.doFilter(request, response);
|
|
|
+ } finally {
|
|
|
+ MDC.remove(MDC_AUTH_KEY);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isExempt(String path) {
|
|
|
+ if (props.getExemptPaths() == null) return false;
|
|
|
+ for (String pat : props.getExemptPaths()) {
|
|
|
+ if (pathMatcher.match(pat, path)) return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean empty(String s) {
|
|
|
+ return s == null || s.isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void write(HttpServletResponse resp, int status, String code) throws IOException {
|
|
|
+ resp.setStatus(status);
|
|
|
+ resp.setCharacterEncoding("UTF-8");
|
|
|
+ resp.setContentType("application/json;charset=UTF-8");
|
|
|
+ McR body = McR.error(code, code);
|
|
|
+ resp.getOutputStream().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+}
|