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:
WorkBuddy
2026-09-30 12:10:00 +08:00
parent 1ad9793ea2
commit ca0db57f24
53 changed files with 1256 additions and 83 deletions
+2 -2
View File
@@ -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>
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
@@ -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);
@@ -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;
@@ -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);
@@ -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