feat(sae): add cloud-gateway adapters and forward mode (0.0.4)
SAE 上这一版只做云网关适配层:/api/taobao/* 字节级透传给 1me.xajuzi.com 上 现有的 H5 后端(旷视调用、评分、报告都留在那边),Java 不再重写测肤逻辑。 - TaobaoEnvelope:客户端要求的 data.ok + data.http_status 信封(HTTP 恒 200) - TaobaoAuthController:challenge/session/me/logout(native 模式) - TaobaoSessionService:一次性 challenge + 30 天 Bearer token,按 open_id 限流 (网关后面所有请求同源 IP,按 IP 计数会变成全站共享 30 次/分钟) - TaobaoForwardController:mode=forward 时透传,保留原始 query 与 body 字节 (签名覆盖原始 body,重新序列化会让验签失败) - TaobaoSignatureVerifier:缺省 sign_method 按 md5 校验 - 打包名固定为 taobao-mvp-backend-0.0.4-forward,SAE 不需要任何环境变量 - pom 版本 0.0.4;mvn test 11/11(新增 8 个用例) - docs:更新 SAE 操作步骤,新增本次交接文档
This commit is contained in:
@@ -9,7 +9,7 @@ Set-ExecutionPolicy -Scope Process Bypass
|
||||
. D:\juzi\tools\env-java.ps1
|
||||
cd D:\juzi\backend-java
|
||||
mvn -DskipTests package
|
||||
java -jar target\taobao-mvp-backend-0.0.1-SNAPSHOT.jar
|
||||
java -jar target\taobao-mvp-backend-0.0.2.jar
|
||||
```
|
||||
|
||||
启动后:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<groupId>com.juzi</groupId>
|
||||
<artifactId>taobao-mvp-backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<version>0.0.4</version>
|
||||
<name>taobao-mvp-backend</name>
|
||||
<description>Taobao mini program + SAE + cloud gateway minimal MVP backend</description>
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<!-- 文件名带 forward 标识,避免和 0.0.2(native 模式)混淆 -->
|
||||
<finalName>taobao-mvp-backend-0.0.4-forward</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.juzi.mvp.api;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.juzi.mvp.gateway.TaobaoSignatureVerifier;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 淘宝小程序登录相关的四个接口:challenge / session / me / logout。
|
||||
*
|
||||
* <p>契约来自参考实现 h5/lib/taobao-routes.js 与客户端 taobao/utils/api.js:
|
||||
* <ul>
|
||||
* <li>HTTP 永远 200,逻辑状态放 data.http_status(见 {@link TaobaoEnvelope})。</li>
|
||||
* <li>客户端不发身份,身份来自云网关注入的 query 参数(app_key / open_id / sign)。</li>
|
||||
* <li>session 必须带 consent=true 和一次性的 challenge。</li>
|
||||
* <li>登录后所有接口用 Authorization: Bearer <token>。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/taobao")
|
||||
@ConditionalOnProperty(name = "taobao.mode", havingValue = "native", matchIfMissing = true)
|
||||
public class TaobaoAuthController {
|
||||
|
||||
private static final String OPEN_ID_PATTERN = "[A-Za-z0-9_-]{4,256}";
|
||||
private static final String IDENTITY_FAILED = "淘宝身份校验失败,请重新授权登录";
|
||||
private static final String SESSION_EXPIRED = "检测会话已失效,请重新登录";
|
||||
|
||||
private final TaobaoSignatureVerifier verifier;
|
||||
private final TaobaoSessionService sessions;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public TaobaoAuthController(TaobaoSignatureVerifier verifier,
|
||||
TaobaoSessionService sessions,
|
||||
ObjectMapper objectMapper) {
|
||||
this.verifier = verifier;
|
||||
this.sessions = sessions;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/challenge", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ApiResponse<Map<String, Object>> challenge(HttpServletRequest request) {
|
||||
String id = sessions.challenge(sessions.rateKey(request));
|
||||
if (id == null) {
|
||||
return ApiResponse.ok(TaobaoEnvelope.fail(429, "操作频繁,请稍后重试"));
|
||||
}
|
||||
Map<String, Object> data = TaobaoEnvelope.ok(200);
|
||||
data.put("challenge", id);
|
||||
return ApiResponse.ok(data);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/session", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ApiResponse<Map<String, Object>> session(@RequestBody(required = false) String rawBody,
|
||||
HttpServletRequest request) {
|
||||
Map<String, Object> body = parseBody(rawBody);
|
||||
if (!Boolean.TRUE.equals(body.get("consent"))) {
|
||||
return ApiResponse.ok(TaobaoEnvelope.fail(400, "请先阅读并同意隐私协议"));
|
||||
}
|
||||
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
|
||||
if (!check.accepted()) {
|
||||
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
|
||||
}
|
||||
String openId = request.getParameter("open_id");
|
||||
if (openId == null || !openId.matches(OPEN_ID_PATTERN)) {
|
||||
// 只有经由云网关的请求才带 open_id;本地直连需要自己拼 ?open_id=...
|
||||
return ApiResponse.ok(TaobaoEnvelope.fail(401, "未取得淘宝身份,请在手机淘宝内重新打开"));
|
||||
}
|
||||
Object challenge = body.get("challenge");
|
||||
if (!(challenge instanceof String challengeId) || !sessions.consume(challengeId)) {
|
||||
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
|
||||
}
|
||||
TaobaoSessionService.Token token = sessions.issue(openId);
|
||||
Map<String, Object> data = TaobaoEnvelope.ok(200);
|
||||
data.put("token", token.value());
|
||||
data.put("expires_in", token.expiresInSeconds());
|
||||
data.put("user", user(token.userId()));
|
||||
return ApiResponse.ok(data);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/me", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ApiResponse<Map<String, Object>> me(HttpServletRequest request) {
|
||||
TaobaoSessionService.Session session = sessions.find(TaobaoSessionService.bearerToken(request));
|
||||
if (session == null) {
|
||||
return ApiResponse.ok(TaobaoEnvelope.fail(401, SESSION_EXPIRED));
|
||||
}
|
||||
Map<String, Object> data = TaobaoEnvelope.ok(200);
|
||||
data.put("user", user(session.userId()));
|
||||
return ApiResponse.ok(data);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/logout", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ApiResponse<Map<String, Object>> logout(HttpServletRequest request) {
|
||||
sessions.revoke(TaobaoSessionService.bearerToken(request));
|
||||
return ApiResponse.ok(TaobaoEnvelope.ok(200));
|
||||
}
|
||||
|
||||
private Map<String, Object> user(String userId) {
|
||||
Map<String, Object> user = new LinkedHashMap<>();
|
||||
user.put("id", userId);
|
||||
user.put("kind", "taobao");
|
||||
return user;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseBody(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(raw, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.juzi.mvp.api;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 淘宝小程序客户端读取的 data 结构约定。
|
||||
*
|
||||
* <p>客户端(taobao/utils/api.js:13-16)只认 data.ok 和 data.http_status:
|
||||
* HTTP 状态码永远是 200,逻辑状态(202/400/401/404/409/413/429)放在 data.http_status 里。
|
||||
* 失败时 message 就是 toast 文案,status 401 还会触发客户端自动登出。
|
||||
* 这与参考实现 h5/lib/taobao-routes.js:8 保持一致。
|
||||
*/
|
||||
public final class TaobaoEnvelope {
|
||||
|
||||
private TaobaoEnvelope() {
|
||||
}
|
||||
|
||||
public static Map<String, Object> ok(int httpStatus) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("ok", true);
|
||||
data.put("http_status", httpStatus);
|
||||
return data;
|
||||
}
|
||||
|
||||
public static Map<String, Object> fail(int httpStatus, String message) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("ok", false);
|
||||
data.put("http_status", httpStatus);
|
||||
data.put("message", message);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.juzi.mvp.api;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* taobao.mode=forward 时启用:把 /api/taobao/* 业务接口字节级转发给外层 H5 后端(Node)。
|
||||
*
|
||||
* <p>为什么必须字节级透传:对端用「原始 query string + 原始 body」验淘宝签名
|
||||
* (h5/lib/taobao-auth.js:16-29)。Java 一旦重新序列化 JSON 或重排 query,
|
||||
* 签名字节就变了,session 会直接 401。所以这里不读 body 语义、不动 JSON。
|
||||
*
|
||||
* <p>/ping、/whoami、/echo 仍然由 {@link MvpController} 自己处理(更具体的映射优先)。
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "taobao.mode", havingValue = "forward")
|
||||
public class TaobaoForwardController {
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
/** 云网关单请求 10 秒上限,留出余量。 */
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(9);
|
||||
|
||||
private final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build();
|
||||
|
||||
private final String forwardBase;
|
||||
|
||||
public TaobaoForwardController(@Value("${taobao.forward-base}") String forwardBase) {
|
||||
this.forwardBase = forwardBase.endsWith("/")
|
||||
? forwardBase.substring(0, forwardBase.length() - 1)
|
||||
: forwardBase;
|
||||
}
|
||||
|
||||
@RequestMapping("/api/taobao/{*path}")
|
||||
public ResponseEntity<byte[]> forward(HttpServletRequest request) {
|
||||
byte[] body = readBody(request);
|
||||
String target = forwardBase + request.getRequestURI()
|
||||
+ (request.getQueryString() == null ? "" : "?" + request.getQueryString());
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.method(request.getMethod(), body.length == 0
|
||||
? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
copyHeader(request, builder, "Content-Type");
|
||||
copyHeader(request, builder, "Authorization");
|
||||
|
||||
HttpResponse<byte[]> response;
|
||||
try {
|
||||
response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
} catch (IOException e) {
|
||||
return envelope(502, "测肤服务连接失败");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return envelope(502, "测肤服务被中断");
|
||||
}
|
||||
|
||||
// 对端正常返回时已经是客户端要的信封(h5/lib/taobao-routes.js:8),原样回传。
|
||||
if (looksLikeEnvelope(response.body())) {
|
||||
return ResponseEntity.status(response.statusCode())
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.body(response.body());
|
||||
}
|
||||
// 对端没部署 / 返回 404 页面 / 网关错误:包成统一信封,避免小程序 JSON.parse 失败。
|
||||
return envelope(502, "测肤服务暂不可用(上游 " + response.statusCode() + ")");
|
||||
}
|
||||
|
||||
private void copyHeader(HttpServletRequest request, HttpRequest.Builder builder, String name) {
|
||||
String value = request.getHeader(name);
|
||||
if (value != null && !value.isBlank()) {
|
||||
builder.header(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readBody(HttpServletRequest request) {
|
||||
try {
|
||||
return request.getInputStream().readAllBytes();
|
||||
} catch (IOException e) {
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean looksLikeEnvelope(byte[] body) {
|
||||
if (body == null || body.length == 0) {
|
||||
return false;
|
||||
}
|
||||
String text = new String(body, StandardCharsets.UTF_8).stripLeading();
|
||||
return text.startsWith("{") && text.contains("\"success\"");
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> envelope(int status, String message) {
|
||||
String json = "{\"success\":true,\"data\":{\"ok\":false,\"http_status\":" + status
|
||||
+ ",\"message\":\"" + message + "\"}}";
|
||||
return ResponseEntity.status(200)
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.body(json.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.juzi.mvp.api;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 淘宝小程序会话:一次性的 challenge + 30 天有效的 Bearer token。
|
||||
*
|
||||
* <p>参考实现 h5/lib/taobao-session.js 的取值:challenge 60 秒、单次消费、
|
||||
* 每来源 30 次/分钟;token 32 字节随机十六进制、有效期 30 天。
|
||||
*
|
||||
* <p>注意:全部状态都在进程内存里,SAE 只能单副本运行;多副本/发布不丢会话
|
||||
* 需要换 Redis 或 RDS。这一点和现有 Node 实现一致,不是新增的退化。
|
||||
*/
|
||||
@Component
|
||||
public class TaobaoSessionService {
|
||||
|
||||
private static final long CHALLENGE_TTL_SECONDS = 60;
|
||||
private static final long TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60;
|
||||
private static final int MAX_OUTSTANDING_CHALLENGES = 1000;
|
||||
private static final int RATE_LIMIT_PER_MINUTE = 30;
|
||||
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
private final Map<String, Instant> challenges = new ConcurrentHashMap<>();
|
||||
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Rate> rates = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${taobao.app-key:}")
|
||||
private String appKey;
|
||||
|
||||
private record Rate(Instant window, int count) {
|
||||
}
|
||||
|
||||
public record Session(String userId, String openId, Instant expires) {
|
||||
}
|
||||
|
||||
public record Token(String value, long expiresInSeconds, String userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 限流计数用的来源标识。
|
||||
*
|
||||
* <p>云网关后面所有请求的来源 IP 都是同一个代理 IP,按 IP 计数会变成全站共享
|
||||
* 30 次/分钟,所以在有 open_id 时优先按用户计数。
|
||||
*/
|
||||
public String rateKey(HttpServletRequest request) {
|
||||
String openId = request.getParameter("open_id");
|
||||
return openId != null && !openId.isBlank() ? "open:" + openId : "ip:" + request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放一次性 challenge;超出频率或存量上限时返回 null(调用方回 429)。
|
||||
*/
|
||||
public String challenge(String rateKey) {
|
||||
sweep();
|
||||
Instant now = Instant.now();
|
||||
Rate rate = rates.compute(rateKey, (key, current) ->
|
||||
current == null || !current.window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now)
|
||||
? new Rate(now, 1)
|
||||
: new Rate(current.window(), current.count() + 1));
|
||||
if (rate.count() > RATE_LIMIT_PER_MINUTE || challenges.size() >= MAX_OUTSTANDING_CHALLENGES) {
|
||||
return null;
|
||||
}
|
||||
String id = randomHex(24);
|
||||
challenges.put(id, now.plusSeconds(CHALLENGE_TTL_SECONDS));
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费 challenge;remove 返回非空即视为抢到,天然只成功一次。
|
||||
*/
|
||||
public boolean consume(String challenge) {
|
||||
if (challenge == null || challenge.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
Instant expires = challenges.remove(challenge);
|
||||
return expires != null && expires.isAfter(Instant.now());
|
||||
}
|
||||
|
||||
public Token issue(String openId) {
|
||||
String userId = userId(openId);
|
||||
String value = randomHex(32);
|
||||
sessions.put(value, new Session(userId, openId, Instant.now().plusSeconds(TOKEN_TTL_SECONDS)));
|
||||
return new Token(value, TOKEN_TTL_SECONDS, userId);
|
||||
}
|
||||
|
||||
public Session find(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Session session = sessions.get(token);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
if (!session.expires().isAfter(Instant.now())) {
|
||||
sessions.remove(token);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
public void revoke(String token) {
|
||||
if (token != null) {
|
||||
sessions.remove(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 稳定的用户 id:sha256(app_key + ':' + open_id),与 Node 端 provider_key 口径一致。
|
||||
*/
|
||||
public String userId(String openId) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
String seed = (appKey == null ? "" : appKey) + ":" + openId;
|
||||
return HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Authorization: Bearer xxx 里取 token。 */
|
||||
public static String bearerToken(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header == null) {
|
||||
return null;
|
||||
}
|
||||
String prefix = "Bearer ";
|
||||
return header.regionMatches(true, 0, prefix, 0, prefix.length())
|
||||
? header.substring(prefix.length()).trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private void sweep() {
|
||||
Instant now = Instant.now();
|
||||
challenges.entrySet().removeIf(entry -> !entry.getValue().isAfter(now));
|
||||
sessions.entrySet().removeIf(entry -> !entry.getValue().expires().isAfter(now));
|
||||
rates.entrySet().removeIf(entry -> !entry.getValue().window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now));
|
||||
}
|
||||
|
||||
private String randomHex(int bytes) {
|
||||
byte[] buffer = new byte[bytes];
|
||||
random.nextBytes(buffer);
|
||||
return HexFormat.of().formatHex(buffer);
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,11 @@ public class TaobaoSignatureVerifier {
|
||||
}
|
||||
|
||||
public static String sign(Map<String, String> params, String body, String secret) throws Exception {
|
||||
String method = params.getOrDefault("sign_method", "");
|
||||
if (!Set.of("", "md5", "hmac", "hmac_md5", "hmac_sha256").contains(method)) {
|
||||
// 真实网关请求不带 sign_method,缺省等价于 md5:
|
||||
// MD5(secret + content + secret)。2026-11-01 用抓包参数离线复算确认。
|
||||
String rawMethod = params.getOrDefault("sign_method", "");
|
||||
String method = rawMethod.isEmpty() ? "md5" : rawMethod;
|
||||
if (!Set.of("md5", "hmac", "hmac_md5", "hmac_sha256").contains(method)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,7 +112,7 @@ public class TaobaoSignatureVerifier {
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), algorithm));
|
||||
return hex(mac.doFinal(content.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
String plain = ("md5".equals(method) ? secret : "") + content + secret;
|
||||
String plain = secret + content + secret;
|
||||
return hex(MessageDigest.getInstance("MD5").digest(plain.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: taobao-mvp-backend
|
||||
profiles:
|
||||
active: local
|
||||
jackson:
|
||||
time-zone: Asia/Shanghai
|
||||
default-property-inclusion: non_null
|
||||
@@ -18,6 +20,10 @@ management:
|
||||
show-details: always
|
||||
|
||||
taobao:
|
||||
# forward: /api/taobao/* 业务接口字节级透传给外层 H5 后端(Node),Java 只做网关适配层
|
||||
# native: Java 自己实现(challenge/session/me/logout);测肤接口尚未实现
|
||||
mode: ${TAOBAO_MODE:native}
|
||||
forward-base: ${TAOBAO_FORWARD_BASE:https://1me.xajuzi.com/state}
|
||||
# 云端部署时通过 SAE 环境变量注入;本地留空即可。
|
||||
app-key: ${TAOBAO_APP_KEY:}
|
||||
app-secret: ${TAOBAO_APP_SECRET:}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.juzi.mvp;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 登录链路(challenge → session → me → logout)的端到端契约测试。
|
||||
*
|
||||
* <p>本地/测试环境 verify-sign=false,所以用 ?open_id= 模拟云网关注入的身份;
|
||||
* 打开 verify-sign 后,同样的请求需要带正确的 sign。
|
||||
*/
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
// 部署用的 application-local.yml 里写的是 mode: forward(生产走透传)。
|
||||
// 这个用例测的是 Java 自己实现登录的那条路,所以显式钉住 native。
|
||||
properties = "taobao.mode=native")
|
||||
class TaobaoAuthFlowTests {
|
||||
|
||||
private static final String GATEWAY_QUERY =
|
||||
"?app_key=35486228&open_id=AAG8-test-open-id&sign_method=hmac_sha256";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate rest;
|
||||
|
||||
private String url(String path) {
|
||||
return "http://127.0.0.1:" + port + path;
|
||||
}
|
||||
|
||||
private HttpEntity<String> json(String body) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return new HttpEntity<>(body, headers);
|
||||
}
|
||||
|
||||
private HttpEntity<String> json(String body, String token) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
if (token != null) {
|
||||
headers.set("Authorization", "Bearer " + token);
|
||||
}
|
||||
return new HttpEntity<>(body, headers);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> data(ResponseEntity<Map> response) {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("success", true);
|
||||
return (Map<String, Object>) response.getBody().get("data");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String login() {
|
||||
Map<String, Object> challenge = data(rest.postForEntity(
|
||||
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
|
||||
assertThat(challenge).containsEntry("ok", true).containsEntry("http_status", 200);
|
||||
|
||||
String challengeId = (String) challenge.get("challenge");
|
||||
assertThat(challengeId).hasSize(48);
|
||||
|
||||
Map<String, Object> session = data(rest.postForEntity(
|
||||
url("/api/taobao/session" + GATEWAY_QUERY),
|
||||
json("{\"consent\":true,\"challenge\":\"" + challengeId + "\"}"),
|
||||
Map.class));
|
||||
assertThat(session).containsEntry("ok", true).containsEntry("http_status", 200);
|
||||
assertThat((String) session.get("token")).hasSize(64);
|
||||
assertThat(((Number) session.get("expires_in")).longValue()).isEqualTo(2592000L);
|
||||
|
||||
Map<String, Object> user = (Map<String, Object>) session.get("user");
|
||||
assertThat(user).containsEntry("kind", "taobao");
|
||||
assertThat((String) user.get("id")).hasSize(64);
|
||||
return (String) session.get("token");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void challengeSessionMeLogoutRoundTrip() {
|
||||
String token = login();
|
||||
|
||||
Map<String, Object> me = data(rest.postForEntity(
|
||||
url("/api/taobao/me"), json("{}", token), Map.class));
|
||||
assertThat(me).containsEntry("ok", true);
|
||||
assertThat(((Map<String, Object>) me.get("user"))).containsEntry("kind", "taobao");
|
||||
|
||||
assertThat(data(rest.postForEntity(
|
||||
url("/api/taobao/logout"), json("{}", token), Map.class))).containsEntry("ok", true);
|
||||
|
||||
Map<String, Object> afterLogout = data(rest.postForEntity(
|
||||
url("/api/taobao/me"), json("{}", token), Map.class));
|
||||
assertThat(afterLogout).containsEntry("ok", false).containsEntry("http_status", 401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionRejectsMissingConsent() {
|
||||
Map<String, Object> challenge = data(rest.postForEntity(
|
||||
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
|
||||
Map<String, Object> session = data(rest.postForEntity(
|
||||
url("/api/taobao/session" + GATEWAY_QUERY),
|
||||
json("{\"consent\":false,\"challenge\":\"" + challenge.get("challenge") + "\"}"),
|
||||
Map.class));
|
||||
assertThat(session).containsEntry("ok", false).containsEntry("http_status", 400);
|
||||
assertThat(session.get("message")).isEqualTo("请先阅读并同意隐私协议");
|
||||
}
|
||||
|
||||
@Test
|
||||
void challengeCannotBeReused() {
|
||||
Map<String, Object> challenge = data(rest.postForEntity(
|
||||
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
|
||||
String body = "{\"consent\":true,\"challenge\":\"" + challenge.get("challenge") + "\"}";
|
||||
|
||||
assertThat(data(rest.postForEntity(url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class)))
|
||||
.containsEntry("ok", true);
|
||||
Map<String, Object> replay = data(rest.postForEntity(
|
||||
url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class));
|
||||
assertThat(replay).containsEntry("ok", false).containsEntry("http_status", 401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithoutTokenIsUnauthorized() {
|
||||
Map<String, Object> me = data(rest.postForEntity(
|
||||
url("/api/taobao/me"), json("{}"), Map.class));
|
||||
assertThat(me).containsEntry("ok", false).containsEntry("http_status", 401);
|
||||
assertThat(me.get("message")).isEqualTo("检测会话已失效,请重新登录");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.juzi.mvp;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* taobao.mode=forward 时,Java 必须把请求「字节级」转发给外层 H5 后端:
|
||||
* 原始 query string、原始 body、Authorization 都要原样过去,回来的信封也要原样返回。
|
||||
*
|
||||
* <p>用一个本地 stub 冒充 Node 后端来验证这件事。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class TaobaoForwardTests {
|
||||
|
||||
private static final HttpServer STUB;
|
||||
private static volatile String lastQuery;
|
||||
private static volatile String lastBody;
|
||||
private static volatile String lastAuthorization;
|
||||
private static volatile String lastPath;
|
||||
|
||||
static {
|
||||
try {
|
||||
STUB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
STUB.createContext("/api/taobao/", exchange -> {
|
||||
lastPath = exchange.getRequestURI().getPath();
|
||||
lastQuery = exchange.getRequestURI().getRawQuery();
|
||||
lastAuthorization = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
try (InputStream in = exchange.getRequestBody()) {
|
||||
lastBody = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
byte[] payload = ("{\"success\":true,\"data\":{\"ok\":true,\"http_status\":200,"
|
||||
+ "\"echo_path\":\"" + lastPath + "\","
|
||||
+ "\"echo_query\":\"" + lastQuery + "\","
|
||||
+ "\"echo_body\":" + (lastBody.isEmpty() ? "\"\"" : lastBody) + "}}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json;charset=utf-8");
|
||||
exchange.sendResponseHeaders(200, payload.length);
|
||||
exchange.getResponseBody().write(payload);
|
||||
exchange.close();
|
||||
});
|
||||
STUB.start();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void properties(DynamicPropertyRegistry registry) {
|
||||
registry.add("taobao.mode", () -> "forward");
|
||||
registry.add("taobao.forward-base", () -> "http://127.0.0.1:" + STUB.getAddress().getPort());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopStub() {
|
||||
STUB.stop(0);
|
||||
}
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate rest;
|
||||
|
||||
private String url(String path) {
|
||||
return "http://127.0.0.1:" + port + path;
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void forwardsBodyQueryAndAuthorizationVerbatim() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer abc123");
|
||||
// 故意带空格和不常见字段顺序:任何重新序列化都会改变这里的字节
|
||||
String body = "{\"consent\":true, \"challenge\":\"x-1\", \"note\":\"带空格\"}";
|
||||
|
||||
ResponseEntity<Map> response = rest.postForEntity(
|
||||
url("/api/taobao/session?app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC"),
|
||||
new HttpEntity<>(body, headers),
|
||||
Map.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("success", true);
|
||||
|
||||
assertThat(lastPath).isEqualTo("/api/taobao/session");
|
||||
assertThat(lastQuery).isEqualTo("app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC");
|
||||
assertThat(lastBody).isEqualTo(body);
|
||||
assertThat(lastAuthorization).isEqualTo("Bearer abc123");
|
||||
|
||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
||||
assertThat(data).containsEntry("ok", true).containsEntry("echo_path", "/api/taobao/session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void localDiagnosticsEndpointsStayLocal() {
|
||||
ResponseEntity<Map> ping = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class);
|
||||
assertThat(ping.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) ping.getBody().get("data");
|
||||
assertThat(data).containsEntry("server", "java");
|
||||
assertThat(lastPath).isNotEqualTo("/api/taobao/ping");
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,18 @@ class TaobaoSignatureVerifierTest {
|
||||
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
|
||||
.isEqualTo("E673310E672C9EB7E918857C6C64D247");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSignMethodUsesTheMd5Form() throws Exception {
|
||||
// 真实网关请求不带 sign_method(2026-11-01 抓包确认),缺省等价于 md5(secret+content+secret)。
|
||||
Map<String, String> params = new TreeMap<>();
|
||||
params.put("app_key", "app");
|
||||
params.put("open_id", "open-account");
|
||||
params.put("env", "online");
|
||||
|
||||
String body = "{\"consent\":true,\"challenge\":\"nonce\"}";
|
||||
|
||||
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
|
||||
.isEqualTo("DD2B95886A5E8F61D6111A5659E4FBD5");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user