Files
VibeCoding/WebRTCSignalServer/src/test/java/com/ttstd/signaling/AuthFlowTest.java
tongtongstudio 6eb2c7321a feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
2026-08-01 15:13:12 +08:00

293 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}