chore(release): 冻结 v1.1.0 版本
- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0 - 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md): - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast) - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto) - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天) - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入 - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置 - 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖) 未验证项(打标签 ≠ 生产发布): - 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限) - 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收 - 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
This commit is contained in:
+2
-2
@@ -13,9 +13,9 @@
|
||||
|
||||
<groupId>com.danyang.match</groupId>
|
||||
<artifactId>danyang-match-platform</artifactId>
|
||||
<version>1.0.0-RC.1</version>
|
||||
<version>1.1.0</version>
|
||||
<name>Danyang Match Platform</name>
|
||||
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
|
||||
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
|
||||
+77
-3
@@ -1,8 +1,13 @@
|
||||
package com.danyang.match.platform.auth.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
|
||||
import com.danyang.match.platform.auth.service.OperatorAuthService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -17,14 +22,83 @@ import java.util.Map;
|
||||
public class OperatorAuthController {
|
||||
|
||||
private final OperatorAuthService operatorAuthService;
|
||||
private final OperatorCookieWriter cookieWriter;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) {
|
||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
String username = body.get("username");
|
||||
String password = body.get("password");
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
|
||||
}
|
||||
return ApiResponse.ok(operatorAuthService.login(username, password));
|
||||
Map<String, Object> result = operatorAuthService.login(username, password);
|
||||
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌
|
||||
cookieWriter.writeLoginCookies(response,
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"),
|
||||
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Operator Token。
|
||||
*
|
||||
* <p>refresh token 优先取请求体(兼容旧前端/跨端口开发代理),缺省时回退到 HttpOnly Cookie
|
||||
* {@code dy_op_rt}(生产同源场景,前端不持有 refresh token,抗 XSS)。
|
||||
* 响应同时下发新的 access / refresh Cookie,实现刷新即轮换。
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<Map<String, Object>> refresh(@RequestBody(required = false) Map<String, String> body,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
String refreshToken = (body != null) ? body.get("refreshToken") : null;
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
refreshToken = readCookie(request, OperatorCookieWriter.REFRESH_COOKIE);
|
||||
}
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少 Refresh Token");
|
||||
}
|
||||
String operatorId = (body != null) ? body.get("operatorId") : null;
|
||||
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
|
||||
cookieWriter.writeLoginCookies(response,
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"),
|
||||
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
|
||||
* token 取自 Authorization 头,缺省时回退到 access Cookie。
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) {
|
||||
String token = readBearer(request);
|
||||
if (token != null) {
|
||||
try {
|
||||
String operatorId = jwtTokenService.getUserIdFromToken(token);
|
||||
operatorAuthService.logout(operatorId, token);
|
||||
} catch (Exception ignored) {
|
||||
// token 已失效也无妨,仍清除 Cookie
|
||||
}
|
||||
}
|
||||
cookieWriter.clearCookies(response);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
private String readCookie(HttpServletRequest request, String name) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) return null;
|
||||
for (Cookie c : cookies) {
|
||||
if (name.equals(c.getName())) return c.getValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readBearer(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
return header.substring(7);
|
||||
}
|
||||
return readCookie(request, OperatorCookieWriter.ACCESS_COOKIE);
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.danyang.match.platform.auth.cookie;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 管理端鉴权 Cookie 写入工具。
|
||||
*
|
||||
* <p>登录 / 刷新成功时下发 HttpOnly + SameSite=Lax 的 access / refresh Cookie;登出时清除。
|
||||
* Cookie 为 HttpOnly,JS 无法读取,从根本上消除 XSS 读取令牌并外泄的风险(此前令牌存于
|
||||
* 前端 localStorage,XSS 可读写并伪造权限)。
|
||||
*
|
||||
* <p>{@code Secure} 标志由 {@code app.auth-cookie-secure} 控制:生产(https)为 true;
|
||||
* dev / docker(http)为 false,否则浏览器拒绝写入。
|
||||
*
|
||||
* <p>路径固定为 {@code /api/v1}(即后端 servlet context-path),确保只随同源 API 请求携带。
|
||||
*/
|
||||
@Component
|
||||
public class OperatorCookieWriter {
|
||||
|
||||
public static final String ACCESS_COOKIE = "dy_op_at";
|
||||
public static final String REFRESH_COOKIE = "dy_op_rt";
|
||||
|
||||
@Value("${app.auth-cookie-secure:true}")
|
||||
private boolean secure;
|
||||
|
||||
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
|
||||
long accessMaxAgeSec, long refreshMaxAgeSec) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
|
||||
}
|
||||
|
||||
public void clearCookies(HttpServletResponse response) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
|
||||
}
|
||||
|
||||
private ResponseCookie build(String name, String value, long maxAgeSec) {
|
||||
return ResponseCookie.from(name, value)
|
||||
.httpOnly(true)
|
||||
.secure(secure)
|
||||
.sameSite("Lax")
|
||||
.path("/api/v1")
|
||||
.maxAge(maxAgeSec)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -13,10 +13,15 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Mock 微信登录 Provider(开发/测试环境使用)
|
||||
* Mock 微信登录 Provider(开发/测试环境使用)。
|
||||
*
|
||||
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
|
||||
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
|
||||
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
|
||||
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
|
||||
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
|
||||
public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 真实微信登录 Provider(生产环境使用)。
|
||||
*
|
||||
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
|
||||
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
|
||||
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
|
||||
*
|
||||
* <p>安全要点:
|
||||
* <ul>
|
||||
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
|
||||
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
|
||||
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
|
||||
* <li>绝不把 appSecret / session_key 回传给前端。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
|
||||
public class RealWechatLoginProvider implements WechatLoginProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
|
||||
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
|
||||
|
||||
@Value("${wechat.app-id:}")
|
||||
private String appId;
|
||||
|
||||
@Value("${wechat.app-secret:}")
|
||||
private String appSecret;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public Map<String, String> getCodeSession(String code) {
|
||||
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
|
||||
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
|
||||
}
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
|
||||
}
|
||||
|
||||
String url = CODE2SESSION_URL
|
||||
+ "?appid=" + appId
|
||||
+ "&secret=" + appSecret
|
||||
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
|
||||
+ "&grant_type=authorization_code";
|
||||
|
||||
try {
|
||||
String resp = restTemplate.getForObject(url, String.class);
|
||||
JsonNode node = objectMapper.readTree(resp);
|
||||
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
|
||||
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
|
||||
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
|
||||
}
|
||||
String openid = node.has("openid") ? node.get("openid").asText() : null;
|
||||
if (openid == null || openid.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
|
||||
}
|
||||
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
|
||||
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
|
||||
|
||||
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("openid", openid);
|
||||
result.put("unionid", unionid);
|
||||
result.put("session_key", sessionKey);
|
||||
result.put("appid", appId);
|
||||
return result;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[Wechat] code2session 调用异常", e);
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMock() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,9 @@ public class AuthService {
|
||||
|
||||
// 将 refreshToken 存入 Redis(用于撤销)
|
||||
String refreshTokenKey = "auth:refresh_token:" + user.getId();
|
||||
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
||||
// 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
|
||||
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
|
||||
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||
|
||||
// 记录审计日志
|
||||
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
|
||||
@@ -173,9 +175,9 @@ public class AuthService {
|
||||
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
|
||||
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
|
||||
|
||||
// 更新 Redis
|
||||
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
|
||||
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
|
||||
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
||||
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("accessToken", newAccessToken);
|
||||
|
||||
+11
@@ -177,6 +177,11 @@ public class OperatorAuthService {
|
||||
throw new UnauthorizedException("Refresh Token 无效");
|
||||
}
|
||||
|
||||
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
|
||||
if (operatorId == null || operatorId.isBlank()) {
|
||||
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
|
||||
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
||||
throw new UnauthorizedException("Refresh Token 无效");
|
||||
@@ -194,6 +199,12 @@ public class OperatorAuthService {
|
||||
result.put("accessToken", accessToken);
|
||||
result.put("refreshToken", newRefreshToken);
|
||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||
result.put("name", operatorInfo.get("name"));
|
||||
result.put("username", operatorInfo.get("username"));
|
||||
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||
result.put("permissions", operatorInfo.get("permissions"));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,9 +215,21 @@ public class JwtTokenService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 过期时间
|
||||
* 获取 Access Token 过期时间(毫秒)
|
||||
*/
|
||||
public long getExpirationMs() {
|
||||
return accessExpirationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Refresh Token 过期时间(毫秒)。
|
||||
*
|
||||
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
|
||||
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
|
||||
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
|
||||
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
|
||||
*/
|
||||
public long getRefreshExpirationMs() {
|
||||
return refreshExpirationMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,25 @@ public class MeController {
|
||||
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认直传照片落库(REST §29 补)。
|
||||
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
|
||||
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
|
||||
* POST /api/v1/me/photos/confirm
|
||||
*/
|
||||
@PostMapping("/photos/confirm")
|
||||
public ApiResponse<Map<String, Object>> confirmPhoto(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String fileKey = body.get("fileKey");
|
||||
String photoType = body.getOrDefault("photoType", "lifestyle");
|
||||
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("photoId", photo.getId());
|
||||
result.put("photoType", photo.getPhotoType());
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除本人照片(REST §32)。
|
||||
* DELETE /api/v1/me/photos/{photoId}
|
||||
|
||||
@@ -187,6 +187,31 @@ public class PhotoService {
|
||||
return photo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认直传照片落库(客户端直传对象存储成功后调用)。
|
||||
*
|
||||
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
|
||||
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
|
||||
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
|
||||
* <ol>
|
||||
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
|
||||
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
|
||||
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
|
||||
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Transactional
|
||||
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
|
||||
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
|
||||
}
|
||||
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
|
||||
}
|
||||
int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1;
|
||||
return savePhoto(userId, fileKey, photoType, sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -58,12 +59,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
String token = null;
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
} else {
|
||||
// 浏览器同源请求:管理端登录后由 HttpOnly Cookie(dy_op_at)携带 access token。
|
||||
// 作为 Authorization 头的兜底(抗 XSS 读取;小程序/用户端仍走 Authorization 头,不受影响)。
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies != null) {
|
||||
for (Cookie c : cookies) {
|
||||
if ("dy_op_at".equals(c.getName())) {
|
||||
token = c.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (token == null) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
if (!jwtTokenService.validateToken(token)) {
|
||||
sendUnauthorized(response);
|
||||
return;
|
||||
|
||||
+7
-2
@@ -12,10 +12,15 @@ import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Mock 实名认证 Provider
|
||||
* 开发/测试环境使用,生产环境替换为正式 Provider
|
||||
* 开发/测试环境使用,生产环境替换为正式 Provider。
|
||||
*
|
||||
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code identity.verification.provider},
|
||||
* 由 {@link RealIdentityVerificationProvider} 承接 {@code real} 取值;本类保留
|
||||
* {@code matchIfMissing = true},未显式配置时本地默认仍是 Mock。生产必须在
|
||||
* {@code application-production.yml} 显式置 {@code identity.verification.provider=real}。
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
|
||||
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "mock", matchIfMissing = true)
|
||||
public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.danyang.match.platform.verification.provider;
|
||||
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 真实实名认证 Provider(生产环境使用)。
|
||||
*
|
||||
* <p>由配置 {@code identity.verification.provider=real} 激活;未显式配置时默认是 Mock
|
||||
* (见 {@link MockIdentityVerificationProvider}),因此生产环境必须在
|
||||
* {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
|
||||
*
|
||||
* <p>核验策略(fail-closed,绝不静默放行):
|
||||
* <ul>
|
||||
* <li>本地强校验:身份证号 GB 11643-1999 校验位(mod 11-2)。校验位不符直接判定未通过,
|
||||
* 在发起任何外部调用前拦截明显伪造的证件号。</li>
|
||||
* <li>第三方核验:必须配置 {@code identity.verification.real.api-url} 与
|
||||
* {@code identity.verification.real.api-key}(环境变量 {@code VERIFICATION_API_URL} /
|
||||
* {@code VERIFICATION_API_KEY})。未配置即 fail-fast —— 真实核验无法凭空完成,
|
||||
* 绝不能回退成「放行」。</li>
|
||||
* <li>任何无法判定为明确的「核验通过」的响应,一律按「未通过」处理(fail-closed)。</li>
|
||||
* <li>绝不把身份证号原文作为日志明文输出;仅记录脱敏值。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>对接说明:第三方请求/响应契约需在接入具体厂商(如公安实名、运营商三要素)时最终确认。
|
||||
* 当前实现以「明确的 success/match/verified/passed 布尔为 true」判定通过,其余一律视为未通过。
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "real")
|
||||
public class RealIdentityVerificationProvider implements IdentityVerificationProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RealIdentityVerificationProvider.class);
|
||||
|
||||
/** 身份证号校验位权重(GB 11643-1999,前 17 位) */
|
||||
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
|
||||
/** 校验位映射:索引 = 加权和 mod 11;'X' 表示余数 2 */
|
||||
private static final String CHECK_CODES = "10X98765432";
|
||||
|
||||
@Value("${identity.verification.real.api-url:}")
|
||||
private String apiUrl;
|
||||
|
||||
@Value("${identity.verification.real.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public Map<String, Object> verify(String realName, String idNumber) {
|
||||
if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) {
|
||||
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意证件
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "实名核验未配置第三方核验服务");
|
||||
}
|
||||
if (realName == null || realName.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "实名认证失败:缺少姓名");
|
||||
}
|
||||
|
||||
// 1. 本地校验位强校验:伪造证件号在外部调用前即拒绝
|
||||
if (!isValidChecksum(idNumber)) {
|
||||
log.warn("[Identity] 身份证校验位不合法: idNumber={}", maskIdNumber(idNumber));
|
||||
return buildResult(false, idNumber, realName, null, null, UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
LocalDate birthDate = parseBirthDateFromIdNumber(idNumber);
|
||||
String gender = parseGenderFromIdNumber(idNumber);
|
||||
|
||||
// 2. 第三方核验(fail-closed)
|
||||
boolean matched = callThirdParty(realName, idNumber);
|
||||
return buildResult(matched, idNumber, realName, birthDate.toString(), gender, UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用第三方实名核验。仅当响应中明确出现「通过」语义的布尔字段为 true 时才返回 true;
|
||||
* 其余(缺字段、字段为 false、解析失败、网络异常)一律返回 false(fail-closed)。
|
||||
*/
|
||||
private boolean callThirdParty(String realName, String idNumber) {
|
||||
try {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("realName", realName);
|
||||
body.put("idNumber", idNumber);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + apiKey);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<String> resp = restTemplate.postForEntity(apiUrl, entity, String.class);
|
||||
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
||||
log.warn("[Identity] 第三方核验返回非成功状态: status={}", resp.getStatusCode());
|
||||
return false;
|
||||
}
|
||||
JsonNode node = objectMapper.readTree(resp.getBody());
|
||||
return isExplicitlyVerified(node);
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[Identity] 第三方核验调用异常", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅在响应中明确的「通过」布尔字段为 true 时判定通过。 */
|
||||
private boolean isExplicitlyVerified(JsonNode node) {
|
||||
for (String field : new String[]{"success", "match", "verified", "passed", "isValid"}) {
|
||||
JsonNode v = node.get(field);
|
||||
if (v != null && v.isBoolean() && v.asBoolean()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 兼容 { "code": 0, "message": "ok" } 形态
|
||||
JsonNode code = node.get("code");
|
||||
if (code != null && code.isNumber() && code.asInt() == 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildResult(boolean verified, String idNumber, String realName,
|
||||
String birthDate, String gender, String requestId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("verified", verified);
|
||||
result.put("realName", realName);
|
||||
result.put("idNumberHash", idNumber);
|
||||
result.put("birthDate", birthDate);
|
||||
result.put("gender", gender);
|
||||
result.put("requestId", requestId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMock() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** GB 11643-1999 校验位(mod 11-2)。 */
|
||||
private boolean isValidChecksum(String idNumber) {
|
||||
if (idNumber == null || idNumber.length() != 18) {
|
||||
return false;
|
||||
}
|
||||
if (!idNumber.substring(0, 17).matches("\\d{17}")) {
|
||||
return false;
|
||||
}
|
||||
char last = idNumber.charAt(17);
|
||||
if (!(Character.isDigit(last) || last == 'X' || last == 'x')) {
|
||||
return false;
|
||||
}
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 17; i++) {
|
||||
sum += (idNumber.charAt(i) - '0') * WEIGHTS[i];
|
||||
}
|
||||
char expected = CHECK_CODES.charAt(sum % 11);
|
||||
return Character.toUpperCase(last) == expected;
|
||||
}
|
||||
|
||||
private LocalDate parseBirthDateFromIdNumber(String idNumber) {
|
||||
try {
|
||||
int year = Integer.parseInt(idNumber.substring(6, 10));
|
||||
int month = Integer.parseInt(idNumber.substring(10, 12));
|
||||
int day = Integer.parseInt(idNumber.substring(12, 14));
|
||||
return LocalDate.of(year, month, day);
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now().minusYears(25);
|
||||
}
|
||||
}
|
||||
|
||||
private String parseGenderFromIdNumber(String idNumber) {
|
||||
int genderDigit = Integer.parseInt(idNumber.substring(16, 17));
|
||||
return (genderDigit % 2 == 1) ? "male" : "female";
|
||||
}
|
||||
|
||||
private String maskIdNumber(String idNumber) {
|
||||
if (idNumber == null || idNumber.length() < 10) return "****";
|
||||
return idNumber.substring(0, 6) + "**********" + idNumber.substring(idNumber.length() - 4);
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,17 @@ object-storage:
|
||||
region: us-east-1
|
||||
signature-version: s3v4
|
||||
|
||||
verification:
|
||||
provider: ${VERIFICATION_PROVIDER:mock}
|
||||
api-url: ${VERIFICATION_API_URL:}
|
||||
api-key: ${VERIFICATION_API_KEY:}
|
||||
# 实名核验:docker 本地/CI 默认走 Mock(VERIFICATION_PROVIDER=real 时启用真实实现,需同时配置 api-url/api-key)
|
||||
identity:
|
||||
verification:
|
||||
provider: ${VERIFICATION_PROVIDER:mock}
|
||||
real:
|
||||
api-url: ${VERIFICATION_API_URL:}
|
||||
api-key: ${VERIFICATION_API_KEY:}
|
||||
|
||||
# 管理端鉴权 Cookie:docker 内部为 http,关闭 Secure 标志
|
||||
app:
|
||||
auth-cookie-secure: false
|
||||
|
||||
match:
|
||||
rule-version: 1
|
||||
|
||||
@@ -14,3 +14,27 @@ spring:
|
||||
user:
|
||||
name: disabled
|
||||
password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
|
||||
|
||||
# 微信登录:生产必须显式启用真实实现(wechat.login.provider=real)。
|
||||
# 未配置时本地默认走 Mock;生产若漏配此行,RealWechatLoginProvider 不会激活、
|
||||
# MockWechatLoginProvider 又因 havingValue=mock 不匹配 → 无 WechatLoginProvider Bean,启动即失败(fail-fast,不悄悄放行任意 code)。
|
||||
wechat:
|
||||
login:
|
||||
provider: real
|
||||
app-id: ${WECHAT_APP_ID}
|
||||
app-secret: ${WECHAT_APP_SECRET}
|
||||
|
||||
# 实名核验:生产必须显式启用真实实现(identity.verification.provider=real)。
|
||||
# 未配置时本地默认走 Mock;生产若漏配此行,RealIdentityVerificationProvider 不会激活、
|
||||
# MockIdentityVerificationProvider 又因 havingValue=mock 不匹配 → 无 IdentityVerificationProvider Bean,启动即失败(fail-fast,不悄悄放行任意证件)。
|
||||
# 真实核验还需配置第三方核验服务地址与密钥(缺失则核验请求 fail-fast)。
|
||||
identity:
|
||||
verification:
|
||||
provider: real
|
||||
real:
|
||||
api-url: ${VERIFICATION_API_URL:}
|
||||
api-key: ${VERIFICATION_API_KEY:}
|
||||
|
||||
# 管理端鉴权 Cookie:生产走 https,开启 Secure 标志(HttpOnly + SameSite=Lax 始终生效)
|
||||
app:
|
||||
auth-cookie-secure: true
|
||||
|
||||
Reference in New Issue
Block a user