diff --git a/src/main/java/com/ai/da/common/config/SecurityConfig.java b/src/main/java/com/ai/da/common/config/SecurityConfig.java new file mode 100644 index 00000000..461f148c --- /dev/null +++ b/src/main/java/com/ai/da/common/config/SecurityConfig.java @@ -0,0 +1,31 @@ +package com.ai.da.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/doc.html", + "/swagger-ui/**", + "/swagger-resources/**", + "/v2/api-docs/**", + "/v3/api-docs/**", + "/webjars/**" + ).permitAll() + .anyRequest().permitAll() // 先全部允许,后续根据业务需要收紧 + ); + return http.build(); + } +} diff --git a/src/main/java/com/ai/da/common/interceptor/UserContextInterceptor.java b/src/main/java/com/ai/da/common/interceptor/UserContextInterceptor.java new file mode 100644 index 00000000..a8318d51 --- /dev/null +++ b/src/main/java/com/ai/da/common/interceptor/UserContextInterceptor.java @@ -0,0 +1,51 @@ +package com.ai.da.common.interceptor; + +import com.ai.da.common.context.UserContext; +import com.ai.da.model.vo.AuthPrincipalVo; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 从 Gateway 转发的请求头中读取已鉴权的用户身份,写入 UserContext。 + *
+ * Gateway 验证 JWT 后将 X-User-Id 和 X-User-Info 写入请求头,
+ * 此拦截器负责将信息填充到 ThreadLocal,供业务代码使用。
+ * 不需要 Gateway 鉴权路径(如 login、静态资源)不会有这两个头,
+ * 此时 UserContext 保持为空,业务代码应自行处理。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class UserContextInterceptor implements HandlerInterceptor {
+
+ private static final String USER_ID_HEADER = "X-User-Id";
+ private static final String USER_INFO_HEADER = "X-User-Info";
+
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ String userInfoJson = request.getHeader(USER_INFO_HEADER);
+ if (userInfoJson != null && !userInfoJson.isBlank()) {
+ try {
+ AuthPrincipalVo principal = objectMapper.readValue(userInfoJson, AuthPrincipalVo.class);
+ UserContext.setUserHolder(principal);
+ } catch (Exception e) {
+ log.warn("Failed to parse X-User-Info header: {}", e.getMessage());
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
+ Object handler, Exception ex) {
+ UserContext.delete();
+ }
+}
diff --git a/src/main/java/com/ai/da/common/utils/TokenGenerateUtils.java b/src/main/java/com/ai/da/common/utils/TokenGenerateUtils.java
new file mode 100644
index 00000000..55ee8634
--- /dev/null
+++ b/src/main/java/com/ai/da/common/utils/TokenGenerateUtils.java
@@ -0,0 +1,131 @@
+package com.ai.da.common.utils;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.ai.da.common.constant.CommonConstant;
+import com.ai.da.model.vo.AuthPrincipalVo;
+import com.alibaba.fastjson.JSONObject;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+/**
+ * Token 生成工具类(仅负责生成,不负责鉴权)。
+ * 鉴权逻辑已迁移至 Gateway(GlobalAuthWebFilter)。
+ */
+@Slf4j
+@Component
+public class TokenGenerateUtils {
+
+ private static final String ISSUER = "DWJ";
+
+ @Value("${spring.security.jwtSecret:JWTSECRET}")
+ private String jwtSecret;
+
+ @Value("${spring.security.jwtExpiration:8640000000}")
+ private long jwtExpiration;
+
+ @Value("${spring.security.jwtTokenPrefix:Bearer-}")
+ private String jwtTokenPrefix;
+
+ /**
+ * 生成 JWT Token。
+ * @param principal 用户信息
+ * @return 完整的 token(含 prefix)
+ */
+ public String createToken(AuthPrincipalVo principal) {
+ SecretKey key = buildSigningKey();
+ String token = Jwts.builder()
+ .id(String.valueOf(principal.getId()))
+ .subject(JSONObject.toJSONString(principal))
+ .issuedAt(new Date())
+ .issuer(ISSUER)
+ .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
+ .signWith(key)
+ .compact();
+ return jwtTokenPrefix + token;
+ }
+
+ /**
+ * 获取 Token 过期时间(毫秒)。
+ */
+ public long getJwtExpiration() {
+ return jwtExpiration;
+ }
+
+ /**
+ * 生成用于邮箱变更的简化 Token。
+ * @param userId 用户 ID
+ * @param mailbox 新邮箱
+ * @return token(不含 prefix)
+ */
+ public String createMailboxToken(Long userId, String mailbox) {
+ SecretKey key = buildSigningKey();
+ return Jwts.builder()
+ .id(String.valueOf(userId))
+ .subject(mailbox + "_" + userId)
+ .issuedAt(new Date())
+ .issuer(ISSUER)
+ .expiration(new Date(System.currentTimeMillis() + CommonConstant.CHANGE_MAILBOX_LINK_VALIDITY))
+ .signWith(key)
+ .compact();
+ }
+
+ /**
+ * 验证 Token 是否有效(签名和有效期)。
+ */
+ public boolean validateToken(String token) {
+ try {
+ Claims claims = parseTokenBody(token);
+ return claims != null;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * 从 Token 中解析用户信息。
+ */
+ public AuthPrincipalVo parserToUser(String token) {
+ try {
+ String subject = parseTokenBody(token).getSubject();
+ if (StrUtil.isNotEmpty(subject)) {
+ return JSONObject.parseObject(subject, AuthPrincipalVo.class);
+ }
+ } catch (Exception e) {
+ log.error("JWT解析用户信息失败: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * 解析邮箱变更 Token,返回 "email_id" 格式字符串。
+ */
+ public String parseMailboxToken(String token) {
+ return parseTokenBody(token).getSubject();
+ }
+
+ private Claims parseTokenBody(String token) {
+ SecretKey key = buildSigningKey();
+ return Jwts.parser()
+ .verifyWith(key)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+
+ private SecretKey buildSigningKey() {
+ byte[] raw = jwtSecret.getBytes(StandardCharsets.UTF_8);
+ if (raw.length < 32) {
+ raw = DigestUtil.sha256(raw);
+ }
+ return Keys.hmacShaKeyFor(raw);
+ }
+}
diff --git a/src/main/java/com/ai/da/feign/gateway/GatewayFeignClient.java b/src/main/java/com/ai/da/feign/gateway/GatewayFeignClient.java
new file mode 100644
index 00000000..03c2dc91
--- /dev/null
+++ b/src/main/java/com/ai/da/feign/gateway/GatewayFeignClient.java
@@ -0,0 +1,21 @@
+package com.ai.da.feign.gateway;
+
+import com.ai.da.common.response.Response;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * 调用 Gateway 黑名单接口,将指定用户的 token 加入黑名单。
+ * 替代原来的 SellerFeignClient.clearTokenCache。
+ */
+@FeignClient(name = "aida-gateway", path = "/internal")
+public interface GatewayFeignClient {
+
+ /**
+ * 将用户 token 加入黑名单。
+ * 后续 Gateway 会拒绝携带该用户 token 的请求。
+ */
+ @PostMapping("/logout")
+ Response