fix: enforce strict session expiry
This commit is contained in:
parent
2a3cfa0ac4
commit
d99b6d6e6d
@ -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 有效期;生产限制为 300~2592000 秒 |
|
||||||
| `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`。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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()) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user