fix: enforce strict session expiry
This commit is contained in:
parent
2a3cfa0ac4
commit
d99b6d6e6d
@ -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`。
|
||||
|
||||
## 本地开发
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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<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
|
||||
void malformedTokenRejects() {
|
||||
assertTrue(service.verify(null).isEmpty());
|
||||
@ -90,4 +127,14 @@ class SessionTokenServiceTest {
|
||||
assertTrue(u.isCustomer());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user