feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user