fix: enforce strict session expiry

This commit is contained in:
malei 2026-08-02 03:28:02 +08:00
parent 2a3cfa0ac4
commit d99b6d6e6d
7 changed files with 92 additions and 5 deletions

View File

@ -21,7 +21,7 @@
| `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID | | `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID |
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret | | `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 | | `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 有效期;生产限制为 3002592000 秒 |
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL用于生成媒体绝对 URL | | `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL用于生成媒体绝对 URL |
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 | | `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
| `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 | | `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 |
@ -38,6 +38,8 @@ CORS 只由全局 `CorsFilter` 和 `CORS_ALLOWED_ORIGINS` 控制Controller
微信出站调用的日志只允许记录 HTTP 状态码、微信错误码和异常类型禁止记录完整响应体、手机号、access_token、AppSecret、一次性 code 或可能包含带凭据 URI 的异常消息。 微信出站调用的日志只允许记录 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` > ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`
## 本地开发 ## 本地开发

View File

@ -10,6 +10,7 @@ MYSQL_PASSWORD=
WECHAT_APPID= WECHAT_APPID=
WECHAT_APPSECRET= WECHAT_APPSECRET=
PETSTORE_SESSION_SECRET= PETSTORE_SESSION_SECRET=
PETSTORE_SESSION_TTL_SECONDS=604800
SMS_UNIVERSAL_CODE= SMS_UNIVERSAL_CODE=
APP_BASE_URL=https://api.petstore.invalid APP_BASE_URL=https://api.petstore.invalid

View File

@ -29,6 +29,11 @@ done
|| fail "APP_BASE_URL must not use a reserved example/test domain" || 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} -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" [[ "$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" [[ -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_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" [[ "${SPRING_JPA_SHOW_SQL:-false}" == "false" ]] || fail "production show-sql must be false"

View File

@ -41,8 +41,11 @@ public class SessionTokenService {
@Value("${auth.session-secret:dev-change-me}") String secret, @Value("${auth.session-secret:dev-change-me}") String secret,
@Value("${auth.session-ttl-seconds:604800}") long ttlSeconds @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.secret = secret.getBytes(StandardCharsets.UTF_8);
this.ttlSeconds = ttlSeconds > 0 ? ttlSeconds : 604800L; this.ttlSeconds = ttlSeconds;
} }
/** 为登录用户签发 session token。 */ /** 为登录用户签发 session token。 */
@ -93,8 +96,8 @@ public class SessionTokenService {
Long userId = toLong(payload.get("userId")); Long userId = toLong(payload.get("userId"));
Long storeId = toLong(payload.get("storeId")); Long storeId = toLong(payload.get("storeId"));
String role = payload.get("role") == null ? "" : payload.get("role").toString(); String role = payload.get("role") == null ? "" : payload.get("role").toString();
long exp = toLong(payload.get("exp")) == null ? 0L : toLong(payload.get("exp")); Long exp = toLong(payload.get("exp"));
if (exp > 0 && Instant.now().getEpochSecond() > exp) { if (exp == null || exp <= Instant.now().getEpochSecond()) {
return java.util.Optional.empty(); return java.util.Optional.empty();
} }
if (userId == null) { if (userId == null) {

View File

@ -21,6 +21,7 @@ public class ProductionConfigurationValidator {
private final String wechatAppId; private final String wechatAppId;
private final String wechatAppSecret; private final String wechatAppSecret;
private final String sessionSecret; private final String sessionSecret;
private final long sessionTtlSeconds;
private final String smsUniversalCode; private final String smsUniversalCode;
private final String appBaseUrl; private final String appBaseUrl;
private final String uploadPath; private final String uploadPath;
@ -34,6 +35,7 @@ public class ProductionConfigurationValidator {
@Value("${wechat.appid:}") String wechatAppId, @Value("${wechat.appid:}") String wechatAppId,
@Value("${wechat.appsecret:}") String wechatAppSecret, @Value("${wechat.appsecret:}") String wechatAppSecret,
@Value("${auth.session-secret:}") String sessionSecret, @Value("${auth.session-secret:}") String sessionSecret,
@Value("${auth.session-ttl-seconds:604800}") long sessionTtlSeconds,
@Value("${app.demo.sms-universal-code:}") String smsUniversalCode, @Value("${app.demo.sms-universal-code:}") String smsUniversalCode,
@Value("${app.base-url:}") String appBaseUrl, @Value("${app.base-url:}") String appBaseUrl,
@Value("${upload.path:}") String uploadPath, @Value("${upload.path:}") String uploadPath,
@ -45,6 +47,7 @@ public class ProductionConfigurationValidator {
this.wechatAppId = wechatAppId; this.wechatAppId = wechatAppId;
this.wechatAppSecret = wechatAppSecret; this.wechatAppSecret = wechatAppSecret;
this.sessionSecret = sessionSecret; this.sessionSecret = sessionSecret;
this.sessionTtlSeconds = sessionTtlSeconds;
this.smsUniversalCode = smsUniversalCode; this.smsUniversalCode = smsUniversalCode;
this.appBaseUrl = appBaseUrl; this.appBaseUrl = appBaseUrl;
this.uploadPath = uploadPath; this.uploadPath = uploadPath;
@ -70,6 +73,9 @@ public class ProductionConfigurationValidator {
if (text(sessionSecret).length() < 32 || "dev-change-me".equals(sessionSecret)) { if (text(sessionSecret).length() < 32 || "dev-change-me".equals(sessionSecret)) {
failures.add("PETSTORE_SESSION_SECRET(至少32字符且非默认值)"); 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必须为空"); if (!text(smsUniversalCode).isEmpty()) failures.add("SMS_UNIVERSAL_CODE必须为空");
requireHttpsOrigin(appBaseUrl, "APP_BASE_URL", failures); requireHttpsOrigin(appBaseUrl, "APP_BASE_URL", failures);
if (text(uploadPath).isEmpty() || !Path.of(uploadPath).isAbsolute()) { if (text(uploadPath).isEmpty() || !Path.of(uploadPath).isAbsolute()) {

View File

@ -1,7 +1,15 @@
package com.petstore.auth; package com.petstore.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; 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 java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@ -11,7 +19,11 @@ import static org.junit.jupiter.api.Assertions.*;
*/ */
class SessionTokenServiceTest { 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 @Test
void issueAndVerifySuccess() { void issueAndVerifySuccess() {
@ -64,6 +76,31 @@ class SessionTokenServiceTest {
assertTrue(opt.isEmpty()); assertTrue(opt.isEmpty());
} }
@Test
void signedPayloadWithoutValidExpiryRejectsFailClosed() throws Exception {
Map<String, Object> 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 @Test
void malformedTokenRejects() { void malformedTokenRejects() {
assertTrue(service.verify(null).isEmpty()); assertTrue(service.verify(null).isEmpty());
@ -90,4 +127,14 @@ class SessionTokenServiceTest {
assertTrue(u.isCustomer()); assertTrue(u.isCustomer());
assertFalse(u.isStoreUser()); assertFalse(u.isStoreUser());
} }
private static String sign(Map<String, Object> 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;
}
} }

View File

@ -49,11 +49,33 @@ class ProductionConfigurationValidatorTest {
assertTrue(pathOrigin.validateValues().contains("CORS_ALLOWED_ORIGINS必须全部为显式生产https源")); 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( private static ProductionConfigurationValidator validator(
String ddlAuto, String ddlAuto,
boolean showSql, boolean showSql,
String smsCode, String smsCode,
String origins) { 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( return new ProductionConfigurationValidator(
ddlAuto, ddlAuto,
showSql, showSql,
@ -62,6 +84,7 @@ class ProductionConfigurationValidatorTest {
"wx-app-id", "wx-app-id",
"wx-app-secret", "wx-app-secret",
"01234567890123456789012345678901", "01234567890123456789012345678901",
sessionTtlSeconds,
smsCode, smsCode,
"https://api.chongxiaota.cn", "https://api.chongxiaota.cn",
"/var/lib/petstore/uploads", "/var/lib/petstore/uploads",