feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View File

@@ -0,0 +1,292 @@
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);
}
}
}