diff --git a/README.md b/README.md index 0c859d3..4c71e23 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID | | `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret | | `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 | -| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期 | +| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期;生产限制为 300~2592000 秒 | | `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL(用于生成媒体绝对 URL) | | `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 | | `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 | @@ -38,6 +38,8 @@ CORS 只由全局 `CorsFilter` 和 `CORS_ALLOWED_ORIGINS` 控制;Controller 微信出站调用的日志只允许记录 HTTP 状态码、微信错误码和异常类型;禁止记录完整响应体、手机号、access_token、AppSecret、一次性 code 或可能包含带凭据 URI 的异常消息。 +Session token 的 `exp` 为必填 Unix 秒时间戳;缺失、非数字、非正或已到期均 fail-closed。非正 TTL 不再静默回退,生产 TTL 只能为 5 分钟至 30 天。 + > ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。 ## 本地开发 diff --git a/deploy/petstore-backend.env.example b/deploy/petstore-backend.env.example index 53fb6f2..91c9c56 100644 --- a/deploy/petstore-backend.env.example +++ b/deploy/petstore-backend.env.example @@ -10,6 +10,7 @@ MYSQL_PASSWORD= WECHAT_APPID= WECHAT_APPSECRET= PETSTORE_SESSION_SECRET= +PETSTORE_SESSION_TTL_SECONDS=604800 SMS_UNIVERSAL_CODE= APP_BASE_URL=https://api.petstore.invalid diff --git a/deploy/release-preflight.sh b/deploy/release-preflight.sh index 31729f3..f01a098 100755 --- a/deploy/release-preflight.sh +++ b/deploy/release-preflight.sh @@ -29,6 +29,11 @@ done || fail "APP_BASE_URL must not use a reserved example/test domain" [[ ${#PETSTORE_SESSION_SECRET} -ge 32 ]] || fail "PETSTORE_SESSION_SECRET must be at least 32 characters" [[ "$PETSTORE_SESSION_SECRET" != "dev-change-me" ]] || fail "PETSTORE_SESSION_SECRET must not use the default" +SESSION_TTL_SECONDS="${PETSTORE_SESSION_TTL_SECONDS:-604800}" +[[ "$SESSION_TTL_SECONDS" =~ ^[0-9]{1,10}$ ]] \ + || fail "PETSTORE_SESSION_TTL_SECONDS must be an integer" +(( SESSION_TTL_SECONDS >= 300 && SESSION_TTL_SECONDS <= 2592000 )) \ + || fail "PETSTORE_SESSION_TTL_SECONDS must be between 300 and 2592000" [[ -z "${SMS_UNIVERSAL_CODE:-}" ]] || fail "SMS_UNIVERSAL_CODE must be empty in production" [[ "${SPRING_JPA_HIBERNATE_DDL_AUTO:-validate}" == "validate" ]] || fail "production ddl-auto must be validate" [[ "${SPRING_JPA_SHOW_SQL:-false}" == "false" ]] || fail "production show-sql must be false" diff --git a/src/main/java/com/petstore/auth/SessionTokenService.java b/src/main/java/com/petstore/auth/SessionTokenService.java index 3c7647e..7ecdd19 100644 --- a/src/main/java/com/petstore/auth/SessionTokenService.java +++ b/src/main/java/com/petstore/auth/SessionTokenService.java @@ -41,8 +41,11 @@ public class SessionTokenService { @Value("${auth.session-secret:dev-change-me}") String secret, @Value("${auth.session-ttl-seconds:604800}") long ttlSeconds ) { + if (ttlSeconds <= 0) { + throw new IllegalArgumentException("auth.session-ttl-seconds must be positive"); + } this.secret = secret.getBytes(StandardCharsets.UTF_8); - this.ttlSeconds = ttlSeconds > 0 ? ttlSeconds : 604800L; + this.ttlSeconds = ttlSeconds; } /** 为登录用户签发 session token。 */ @@ -93,8 +96,8 @@ public class SessionTokenService { Long userId = toLong(payload.get("userId")); Long storeId = toLong(payload.get("storeId")); String role = payload.get("role") == null ? "" : payload.get("role").toString(); - long exp = toLong(payload.get("exp")) == null ? 0L : toLong(payload.get("exp")); - if (exp > 0 && Instant.now().getEpochSecond() > exp) { + Long exp = toLong(payload.get("exp")); + if (exp == null || exp <= Instant.now().getEpochSecond()) { return java.util.Optional.empty(); } if (userId == null) { diff --git a/src/main/java/com/petstore/config/ProductionConfigurationValidator.java b/src/main/java/com/petstore/config/ProductionConfigurationValidator.java index a6e0880..f33b56a 100644 --- a/src/main/java/com/petstore/config/ProductionConfigurationValidator.java +++ b/src/main/java/com/petstore/config/ProductionConfigurationValidator.java @@ -21,6 +21,7 @@ public class ProductionConfigurationValidator { private final String wechatAppId; private final String wechatAppSecret; private final String sessionSecret; + private final long sessionTtlSeconds; private final String smsUniversalCode; private final String appBaseUrl; private final String uploadPath; @@ -34,6 +35,7 @@ public class ProductionConfigurationValidator { @Value("${wechat.appid:}") String wechatAppId, @Value("${wechat.appsecret:}") String wechatAppSecret, @Value("${auth.session-secret:}") String sessionSecret, + @Value("${auth.session-ttl-seconds:604800}") long sessionTtlSeconds, @Value("${app.demo.sms-universal-code:}") String smsUniversalCode, @Value("${app.base-url:}") String appBaseUrl, @Value("${upload.path:}") String uploadPath, @@ -45,6 +47,7 @@ public class ProductionConfigurationValidator { this.wechatAppId = wechatAppId; this.wechatAppSecret = wechatAppSecret; this.sessionSecret = sessionSecret; + this.sessionTtlSeconds = sessionTtlSeconds; this.smsUniversalCode = smsUniversalCode; this.appBaseUrl = appBaseUrl; this.uploadPath = uploadPath; @@ -70,6 +73,9 @@ public class ProductionConfigurationValidator { if (text(sessionSecret).length() < 32 || "dev-change-me".equals(sessionSecret)) { failures.add("PETSTORE_SESSION_SECRET(至少32字符且非默认值)"); } + if (sessionTtlSeconds < 300 || sessionTtlSeconds > 2_592_000) { + failures.add("PETSTORE_SESSION_TTL_SECONDS必须为300至2592000秒"); + } if (!text(smsUniversalCode).isEmpty()) failures.add("SMS_UNIVERSAL_CODE必须为空"); requireHttpsOrigin(appBaseUrl, "APP_BASE_URL", failures); if (text(uploadPath).isEmpty() || !Path.of(uploadPath).isAbsolute()) { diff --git a/src/test/java/com/petstore/auth/SessionTokenServiceTest.java b/src/test/java/com/petstore/auth/SessionTokenServiceTest.java index 2b5071b..2f68e09 100644 --- a/src/test/java/com/petstore/auth/SessionTokenServiceTest.java +++ b/src/test/java/com/petstore/auth/SessionTokenServiceTest.java @@ -1,7 +1,15 @@ package com.petstore.auth; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -11,7 +19,11 @@ import static org.junit.jupiter.api.Assertions.*; */ class SessionTokenServiceTest { - private final SessionTokenService service = new SessionTokenService("test-secret-key-for-unit-test", 60L); + private static final String TEST_SECRET = "test-secret-key-for-unit-test"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private final SessionTokenService service = new SessionTokenService(TEST_SECRET, 60L); @Test void issueAndVerifySuccess() { @@ -64,6 +76,31 @@ class SessionTokenServiceTest { assertTrue(opt.isEmpty()); } + @Test + void signedPayloadWithoutValidExpiryRejectsFailClosed() throws Exception { + Map payload = new LinkedHashMap<>(); + payload.put("userId", 1L); + payload.put("storeId", 10L); + payload.put("role", "boss"); + + assertTrue(service.verify(sign(payload)).isEmpty(), "缺少 exp 必须拒绝"); + + payload.put("exp", 0); + assertTrue(service.verify(sign(payload)).isEmpty(), "非正 exp 必须拒绝"); + + payload.put("exp", Instant.now().getEpochSecond()); + assertTrue(service.verify(sign(payload)).isEmpty(), "到期边界必须拒绝"); + + payload.put("exp", "not-a-number"); + assertTrue(service.verify(sign(payload)).isEmpty(), "非数字 exp 必须拒绝"); + } + + @Test + void nonPositiveTtlRejectsAtConstruction() { + assertThrows(IllegalArgumentException.class, () -> new SessionTokenService(TEST_SECRET, 0L)); + assertThrows(IllegalArgumentException.class, () -> new SessionTokenService(TEST_SECRET, -1L)); + } + @Test void malformedTokenRejects() { assertTrue(service.verify(null).isEmpty()); @@ -90,4 +127,14 @@ class SessionTokenServiceTest { assertTrue(u.isCustomer()); assertFalse(u.isStoreUser()); } + + private static String sign(Map payload) throws Exception { + String payloadB64 = URL_ENCODER.encodeToString(MAPPER.writeValueAsBytes(payload)); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(TEST_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String signature = URL_ENCODER.encodeToString( + mac.doFinal(payloadB64.getBytes(StandardCharsets.UTF_8)) + ); + return payloadB64 + "." + signature; + } } diff --git a/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java b/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java index c8ac730..36d2ad2 100644 --- a/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java +++ b/src/test/java/com/petstore/config/ProductionConfigurationValidatorTest.java @@ -49,11 +49,33 @@ class ProductionConfigurationValidatorTest { assertTrue(pathOrigin.validateValues().contains("CORS_ALLOWED_ORIGINS必须全部为显式生产https源")); } + @Test + void unsafeSessionTtlIsRejected() { + ProductionConfigurationValidator tooShort = validator( + "validate", false, "", "https://admin.chongxiaota.cn", 299 + ); + assertTrue(tooShort.validateValues().contains("PETSTORE_SESSION_TTL_SECONDS必须为300至2592000秒")); + + ProductionConfigurationValidator tooLong = validator( + "validate", false, "", "https://admin.chongxiaota.cn", 2_592_001 + ); + assertTrue(tooLong.validateValues().contains("PETSTORE_SESSION_TTL_SECONDS必须为300至2592000秒")); + } + private static ProductionConfigurationValidator validator( String ddlAuto, boolean showSql, String smsCode, String origins) { + return validator(ddlAuto, showSql, smsCode, origins, 604_800); + } + + private static ProductionConfigurationValidator validator( + String ddlAuto, + boolean showSql, + String smsCode, + String origins, + long sessionTtlSeconds) { return new ProductionConfigurationValidator( ddlAuto, showSql, @@ -62,6 +84,7 @@ class ProductionConfigurationValidatorTest { "wx-app-id", "wx-app-secret", "01234567890123456789012345678901", + sessionTtlSeconds, smsCode, "https://api.chongxiaota.cn", "/var/lib/petstore/uploads",