- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
293 lines
12 KiB
Java
293 lines
12 KiB
Java
package com.ttstd.signaling;
|
||
|
||
import com.ttstd.signaling.model.AuthPrincipal;
|
||
import com.ttstd.signaling.model.DeviceType;
|
||
import com.ttstd.signaling.model.PrincipalType;
|
||
import com.ttstd.signaling.security.AuthException;
|
||
import com.ttstd.signaling.security.JwtService;
|
||
import com.ttstd.signaling.security.SecurityProperties;
|
||
import com.ttstd.signaling.security.TotpService;
|
||
import com.ttstd.signaling.service.AccountService;
|
||
import com.ttstd.signaling.service.AuditService;
|
||
import com.ttstd.signaling.service.DeviceIdentityService;
|
||
import com.ttstd.signaling.service.TokenPair;
|
||
import com.ttstd.signaling.store.InMemoryDeviceStore;
|
||
import com.ttstd.signaling.store.InMemoryNonceStore;
|
||
import com.ttstd.signaling.store.InMemorySessionStore;
|
||
import com.ttstd.signaling.store.InMemoryUserStore;
|
||
import org.junit.jupiter.api.BeforeEach;
|
||
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.HexFormat;
|
||
import java.util.UUID;
|
||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||
|
||
/**
|
||
* 账号机制与握手鉴权核心逻辑测试。
|
||
*/
|
||
class AuthFlowTest {
|
||
|
||
private static final String PROVISION_SECRET = "test-provision-secret-value-0123456789";
|
||
|
||
private SecurityProperties properties;
|
||
private AccountService accountService;
|
||
private DeviceIdentityService deviceIdentityService;
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
properties = new SecurityProperties();
|
||
properties.getJwt().setSecret("test-jwt-secret-key-must-be-at-least-32-bytes-long");
|
||
properties.getDevice().setProvisionSecret(PROVISION_SECRET);
|
||
properties.getAccount().setBootstrapPassword(null);
|
||
|
||
JwtService jwtService = new JwtService(properties);
|
||
TotpService totpService = new TotpService();
|
||
AuditService auditService = new AuditService();
|
||
accountService = new AccountService(
|
||
new InMemoryUserStore(), new InMemorySessionStore(), properties,
|
||
jwtService, totpService, auditService);
|
||
deviceIdentityService = new DeviceIdentityService(
|
||
new InMemoryDeviceStore(), new InMemoryNonceStore(), properties, jwtService);
|
||
}
|
||
|
||
// ==================== 账号 ====================
|
||
|
||
@Test
|
||
void loginReturnsUsableAccessToken() {
|
||
accountService.register("alice", "Xk9#mP2$vLq7");
|
||
TokenPair pair = accountService.login("alice", "Xk9#mP2$vLq7", "127.0.0.1", "junit");
|
||
|
||
assertNotNull(pair.accessToken());
|
||
AuthPrincipal principal = accountService.authenticate(pair.accessToken());
|
||
assertEquals(PrincipalType.USER, principal.principalType());
|
||
assertEquals(DeviceType.CONTROLLER, principal.deviceType());
|
||
assertEquals("alice", principal.displayName());
|
||
// 主控端信令 ID 由服务端派生,不可由客户端指定
|
||
assertTrue(principal.deviceId().startsWith("ctl_"));
|
||
}
|
||
|
||
@Test
|
||
void wrongPasswordIsRejected() {
|
||
accountService.register("bob", "Xk9#mP2$vLq7");
|
||
assertThrows(AuthException.class,
|
||
() -> accountService.login("bob", "WrongPass1!", "127.0.0.1", "junit"));
|
||
}
|
||
|
||
@Test
|
||
void weakPasswordIsRejected() {
|
||
assertThrows(AuthException.class, () -> accountService.register("carol", "short"));
|
||
}
|
||
|
||
@Test
|
||
void duplicateUsernameIsRejected() {
|
||
accountService.register("dave", "Xk9#mP2$vLq7");
|
||
assertThrows(AuthException.class, () -> accountService.register("dave", "Xk9#mP2$vLq7"));
|
||
}
|
||
|
||
/** 封禁后:已签发的 access token 必须立即失效。 */
|
||
@Test
|
||
void banInvalidatesExistingAccessToken() {
|
||
accountService.register("eve", "Xk9#mP2$vLq7");
|
||
TokenPair pair = accountService.login("eve", "Xk9#mP2$vLq7", "127.0.0.1", "junit");
|
||
String userId = pair.principalId();
|
||
|
||
assertNotNull(accountService.authenticate(pair.accessToken()));
|
||
|
||
accountService.ban(userId, null, "违规操作");
|
||
|
||
assertThrows(AuthException.class, () -> accountService.authenticate(pair.accessToken()));
|
||
assertThrows(AuthException.class,
|
||
() -> accountService.login("eve", "Xk9#mP2$vLq7", "127.0.0.1", "junit"));
|
||
}
|
||
|
||
/** 临时封禁到期后应自动恢复。 */
|
||
@Test
|
||
void expiredSuspensionAutoRecovers() {
|
||
accountService.register("frank", "Xk9#mP2$vLq7");
|
||
TokenPair pair = accountService.login("frank", "Xk9#mP2$vLq7", "127.0.0.1", "junit");
|
||
accountService.ban(pair.principalId(), Instant.now().minusSeconds(1), "临时");
|
||
|
||
// 封禁已过期,应能重新登录
|
||
assertNotNull(accountService.login("frank", "Xk9#mP2$vLq7", "127.0.0.1", "junit"));
|
||
}
|
||
|
||
/** 踢出会话后,该会话的 access token 立即不可用。 */
|
||
@Test
|
||
void revokeSessionInvalidatesToken() {
|
||
accountService.register("grace", "Xk9#mP2$vLq7");
|
||
TokenPair pair = accountService.login("grace", "Xk9#mP2$vLq7", "127.0.0.1", "junit");
|
||
|
||
accountService.revokeSession(pair.sessionId(), "管理员踢出");
|
||
|
||
assertThrows(AuthException.class, () -> accountService.authenticate(pair.accessToken()));
|
||
}
|
||
|
||
/** 刷新令牌应轮转,且旧令牌复用会导致整个会话被吊销。 */
|
||
@Test
|
||
void refreshTokenRotatesAndDetectsReuse() {
|
||
accountService.register("heidi", "Xk9#mP2$vLq7");
|
||
TokenPair first = accountService.login("heidi", "Xk9#mP2$vLq7", "127.0.0.1", "junit");
|
||
|
||
TokenPair second = accountService.refresh(first.refreshToken(), "127.0.0.1", "junit");
|
||
assertNotEquals(first.refreshToken(), second.refreshToken());
|
||
|
||
// 复用旧刷新令牌 -> 判定泄露,吊销会话
|
||
assertThrows(AuthException.class,
|
||
() -> accountService.refresh(first.refreshToken(), "127.0.0.1", "junit"));
|
||
// 会话已被吊销,新刷新令牌同样失效
|
||
assertThrows(AuthException.class,
|
||
() -> accountService.refresh(second.refreshToken(), "127.0.0.1", "junit"));
|
||
}
|
||
|
||
/** 连续登录失败应触发锁定。 */
|
||
@Test
|
||
void repeatedFailuresLockAccount() {
|
||
accountService.register("ivan", "Xk9#mP2$vLq7");
|
||
for (int i = 0; i < properties.getAccount().getMaxFailedAttempts(); i++) {
|
||
assertThrows(AuthException.class,
|
||
() -> accountService.login("ivan", "Bad1Pass!", "127.0.0.1", "junit"));
|
||
}
|
||
// 即便密码正确,锁定期内也应拒绝
|
||
AuthException ex = assertThrows(AuthException.class,
|
||
() -> accountService.login("ivan", "Xk9#mP2$vLq7", "127.0.0.1", "junit"));
|
||
assertEquals(429, ex.getStatus());
|
||
}
|
||
|
||
/** 令牌用途隔离:refresh 令牌不可当作 access 令牌使用。 */
|
||
@Test
|
||
void deviceTokenCannotAuthenticateAsUser() {
|
||
DeviceIdentityService.ProvisionResult provisioned = provisionDevice("SN-TEST-0001");
|
||
TokenPair deviceToken = deviceIdentityService.issueDeviceToken(
|
||
provisioned.deviceUid(), provisioned.deviceSecret());
|
||
|
||
assertThrows(AuthException.class,
|
||
() -> accountService.authenticate(deviceToken.accessToken()));
|
||
}
|
||
|
||
// ==================== 设备 ====================
|
||
|
||
@Test
|
||
void provisionAndAuthenticateDevice() {
|
||
DeviceIdentityService.ProvisionResult result = provisionDevice("SN-TEST-1234");
|
||
assertTrue(result.deviceUid().startsWith("dev_"));
|
||
|
||
TokenPair pair = deviceIdentityService.issueDeviceToken(
|
||
result.deviceUid(), result.deviceSecret());
|
||
AuthPrincipal principal = deviceIdentityService.authenticate(pair.accessToken());
|
||
|
||
assertEquals(PrincipalType.DEVICE, principal.principalType());
|
||
assertEquals(DeviceType.CONTROLLED, principal.deviceType());
|
||
// 对外暴露的是 deviceUid,而非 SN
|
||
assertEquals(result.deviceUid(), principal.deviceId());
|
||
}
|
||
|
||
@Test
|
||
void provisionWithBadHmacIsRejected() {
|
||
long ts = Instant.now().getEpochSecond();
|
||
assertThrows(AuthException.class, () -> deviceIdentityService.provision(
|
||
"SN-BAD", "model", UUID.randomUUID().toString(), ts, "deadbeef"));
|
||
}
|
||
|
||
/** nonce 重放必须被拒绝。 */
|
||
@Test
|
||
void provisionReplayIsRejected() {
|
||
String sn = "SN-REPLAY";
|
||
String nonce = UUID.randomUUID().toString();
|
||
long ts = Instant.now().getEpochSecond();
|
||
String hmac = hmacHex(PROVISION_SECRET, sn + "|" + nonce + "|" + ts);
|
||
|
||
deviceIdentityService.provision(sn, "model", nonce, ts, hmac);
|
||
assertThrows(AuthException.class,
|
||
() -> deviceIdentityService.provision(sn, "model", nonce, ts, hmac));
|
||
}
|
||
|
||
/** 过期时间戳必须被拒绝。 */
|
||
@Test
|
||
void provisionWithStaleTimestampIsRejected() {
|
||
String sn = "SN-STALE";
|
||
String nonce = UUID.randomUUID().toString();
|
||
long ts = Instant.now().getEpochSecond() - 99999;
|
||
String hmac = hmacHex(PROVISION_SECRET, sn + "|" + nonce + "|" + ts);
|
||
|
||
assertThrows(AuthException.class,
|
||
() -> deviceIdentityService.provision(sn, "model", nonce, ts, hmac));
|
||
}
|
||
|
||
/** SN 白名单启用后,未授权 SN 不可激活。 */
|
||
@Test
|
||
void allowlistBlocksUnknownSn() {
|
||
properties.getDevice().setSnAllowlistEnabled(true);
|
||
assertThrows(AuthException.class, () -> provisionDevice("SN-NOT-ALLOWED"));
|
||
|
||
deviceIdentityService.importAllowlist(java.util.List.of("SN-ALLOWED"));
|
||
assertNotNull(provisionDevice("SN-ALLOWED"));
|
||
}
|
||
|
||
/** 禁用设备后其令牌立即失效。 */
|
||
@Test
|
||
void disablingDeviceInvalidatesToken() {
|
||
DeviceIdentityService.ProvisionResult result = provisionDevice("SN-DISABLE");
|
||
TokenPair pair = deviceIdentityService.issueDeviceToken(
|
||
result.deviceUid(), result.deviceSecret());
|
||
assertNotNull(deviceIdentityService.authenticate(pair.accessToken()));
|
||
|
||
deviceIdentityService.disable(result.deviceUid(), null, "设备丢失");
|
||
|
||
assertThrows(AuthException.class,
|
||
() -> deviceIdentityService.authenticate(pair.accessToken()));
|
||
}
|
||
|
||
/** 重新激活会轮换密钥,旧 deviceSecret 失效。 */
|
||
@Test
|
||
void reProvisionRotatesSecret() {
|
||
DeviceIdentityService.ProvisionResult first = provisionDevice("SN-ROTATE");
|
||
DeviceIdentityService.ProvisionResult second = provisionDevice("SN-ROTATE");
|
||
|
||
// 同一 SN 复用同一 deviceUid,但密钥已轮换
|
||
assertEquals(first.deviceUid(), second.deviceUid());
|
||
assertNotEquals(first.deviceSecret(), second.deviceSecret());
|
||
|
||
assertThrows(AuthException.class,
|
||
() -> deviceIdentityService.issueDeviceToken(first.deviceUid(), first.deviceSecret()));
|
||
assertNotNull(deviceIdentityService.issueDeviceToken(
|
||
second.deviceUid(), second.deviceSecret()));
|
||
}
|
||
|
||
@Test
|
||
void tamperedTokenIsRejected() {
|
||
accountService.register("judy", "Xk9#mP2$vLq7");
|
||
TokenPair pair = accountService.login("judy", "Xk9#mP2$vLq7", "127.0.0.1", "junit");
|
||
|
||
String tampered = pair.accessToken().substring(0, pair.accessToken().length() - 3) + "aaa";
|
||
assertThrows(AuthException.class, () -> accountService.authenticate(tampered));
|
||
}
|
||
|
||
// ==================== helpers ====================
|
||
|
||
private DeviceIdentityService.ProvisionResult provisionDevice(String sn) {
|
||
String nonce = UUID.randomUUID().toString();
|
||
long ts = Instant.now().getEpochSecond();
|
||
String hmac = hmacHex(PROVISION_SECRET, sn + "|" + nonce + "|" + ts);
|
||
return deviceIdentityService.provision(sn, "test-model", nonce, ts, hmac);
|
||
}
|
||
|
||
private static String hmacHex(String key, String data) {
|
||
try {
|
||
Mac mac = Mac.getInstance("HmacSHA256");
|
||
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
|
||
} catch (Exception e) {
|
||
throw new IllegalStateException(e);
|
||
}
|
||
}
|
||
}
|