feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package com.ttstd.signaling;
|
||||
|
||||
import com.ttstd.signaling.config.BearerAuthFilter;
|
||||
import com.ttstd.signaling.handler.SignalWebSocketHandler;
|
||||
import com.ttstd.signaling.security.AuthHandshakeInterceptor;
|
||||
import com.ttstd.signaling.service.AccountService;
|
||||
import com.ttstd.signaling.service.DeviceIdentityService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* 验证引入鉴权组件后 Spring 上下文可正常装配(含 WebSocket 与过滤器链)。
|
||||
* 使用 memory profile,避免测试依赖外部 MySQL/Redis。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {
|
||||
"security.jwt.secret=integration-test-secret-key-at-least-32-bytes",
|
||||
"security.device.provision-secret=integration-test-provision-secret",
|
||||
// 集成测试使用嵌入式 H2,无需外部 MySQL / Redis
|
||||
"spring.datasource.url=jdbc:h2:mem:signaltest;DB_CLOSE_DELAY=-1;MODE=MySQL",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"spring.data.redis.host=127.0.0.1",
|
||||
"spring.data.redis.port=6390"
|
||||
})
|
||||
@ActiveProfiles("memory")
|
||||
class ApplicationContextTest {
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
@Autowired
|
||||
private DeviceIdentityService deviceIdentityService;
|
||||
@Autowired
|
||||
private AuthHandshakeInterceptor authHandshakeInterceptor;
|
||||
@Autowired
|
||||
private BearerAuthFilter bearerAuthFilter;
|
||||
@Autowired
|
||||
private SignalWebSocketHandler signalWebSocketHandler;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
assertNotNull(accountService);
|
||||
assertNotNull(deviceIdentityService);
|
||||
assertNotNull(authHandshakeInterceptor);
|
||||
assertNotNull(bearerAuthFilter);
|
||||
assertNotNull(signalWebSocketHandler);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ttstd.signaling;
|
||||
|
||||
import com.ttstd.signaling.model.DeviceBinding;
|
||||
import com.ttstd.signaling.repository.UserAccountRepository;
|
||||
import com.ttstd.signaling.service.AuditService;
|
||||
import com.ttstd.signaling.service.BindingService;
|
||||
import com.ttstd.signaling.store.BindingStore;
|
||||
import com.ttstd.signaling.store.BlacklistStore;
|
||||
import com.ttstd.signaling.store.InMemoryBindingStore;
|
||||
import com.ttstd.signaling.store.InMemoryBlacklistStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BindingServiceTest {
|
||||
|
||||
private BindingService bindingService;
|
||||
private UserAccountRepository userRepo;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
BindingStore bindingStore = new InMemoryBindingStore();
|
||||
BlacklistStore blacklistStore = new InMemoryBlacklistStore();
|
||||
userRepo = Mockito.mock(UserAccountRepository.class);
|
||||
bindingService = new BindingService(bindingStore, blacklistStore, userRepo, new AuditService());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindThenIsBound() {
|
||||
bindingService.bind("dev_1", "usr_1", DeviceBinding.BindingRole.MEMBER, "客厅", "device:dev_1");
|
||||
assertTrue(bindingService.isBound("dev_1", "usr_1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundIsNotBound() {
|
||||
assertFalse(bindingService.isBound("dev_2", "usr_2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeThenNotBound() {
|
||||
bindingService.bind("dev_3", "usr_3", DeviceBinding.BindingRole.MEMBER, null, "admin");
|
||||
bindingService.revokeBinding("dev_3", "usr_3", "admin");
|
||||
assertFalse(bindingService.isBound("dev_3", "usr_3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebindAfterRevokeRestoresActive() {
|
||||
bindingService.bind("dev_4", "usr_4", DeviceBinding.BindingRole.MEMBER, null, "admin");
|
||||
bindingService.revokeBinding("dev_4", "usr_4", "admin");
|
||||
assertFalse(bindingService.isBound("dev_4", "usr_4"));
|
||||
bindingService.bind("dev_4", "usr_4", DeviceBinding.BindingRole.OWNER, "again", "admin");
|
||||
assertTrue(bindingService.isBound("dev_4", "usr_4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blacklistOverridesBinding() {
|
||||
bindingService.bind("dev_5", "usr_5", DeviceBinding.BindingRole.MEMBER, null, "admin");
|
||||
assertTrue(bindingService.isBound("dev_5", "usr_5"));
|
||||
bindingService.addBlacklist("dev_5", "usr_5", "骚扰", "device:dev_5");
|
||||
assertTrue(bindingService.isBlacklisted("dev_5", "usr_5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notBlacklistedByDefault() {
|
||||
assertFalse(bindingService.isBlacklisted("dev_6", "usr_6"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeBlacklistWorks() {
|
||||
bindingService.addBlacklist("dev_7", "usr_7", "reason", "admin");
|
||||
assertTrue(bindingService.isBlacklisted("dev_7", "usr_7"));
|
||||
bindingService.removeBlacklist("dev_7", "usr_7", "admin");
|
||||
assertFalse(bindingService.isBlacklisted("dev_7", "usr_7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listByUserReturnsOnlyBindings() {
|
||||
bindingService.bind("dev_a", "usr_x", DeviceBinding.BindingRole.MEMBER, null, "admin");
|
||||
bindingService.bind("dev_b", "usr_x", DeviceBinding.BindingRole.MEMBER, null, "admin");
|
||||
bindingService.bind("dev_c", "usr_y", DeviceBinding.BindingRole.MEMBER, null, "admin");
|
||||
assertEquals(2, bindingService.listByUser("usr_x").size());
|
||||
assertEquals(1, bindingService.listByUser("usr_y").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveUserIdUsesRepository() {
|
||||
com.ttstd.signaling.model.UserAccount u = Mockito.mock(com.ttstd.signaling.model.UserAccount.class);
|
||||
Mockito.when(u.getUserId()).thenReturn("usr_resolved");
|
||||
Mockito.when(userRepo.findByUsername("alice")).thenReturn(java.util.Optional.of(u));
|
||||
assertEquals("usr_resolved", bindingService.resolveUserId("alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveUserIdThrowsForUnknown() {
|
||||
Mockito.when(userRepo.findByUsername("nobody")).thenReturn(java.util.Optional.empty());
|
||||
assertThrows(com.ttstd.signaling.security.AuthException.class,
|
||||
() -> bindingService.resolveUserId("nobody"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ttstd.signaling;
|
||||
|
||||
import com.ttstd.signaling.model.DeviceBinding;
|
||||
import com.ttstd.signaling.repository.UserAccountRepository;
|
||||
import com.ttstd.signaling.security.AuthException;
|
||||
import com.ttstd.signaling.security.SecurityProperties;
|
||||
import com.ttstd.signaling.service.AuditService;
|
||||
import com.ttstd.signaling.service.BindingService;
|
||||
import com.ttstd.signaling.service.PairingService;
|
||||
import com.ttstd.signaling.service.TurnCredentialService;
|
||||
import com.ttstd.signaling.store.InMemoryBindingStore;
|
||||
import com.ttstd.signaling.store.InMemoryBlacklistStore;
|
||||
import com.ttstd.signaling.store.InMemoryPairingStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PairingAndTurnTest {
|
||||
|
||||
private PairingService pairingService;
|
||||
private BindingService bindingService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
bindingService = new BindingService(
|
||||
new InMemoryBindingStore(), new InMemoryBlacklistStore(),
|
||||
Mockito.mock(UserAccountRepository.class), new AuditService());
|
||||
pairingService = new PairingService(
|
||||
new InMemoryPairingStore(), bindingService, new AuditService(), 600);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateThenRedeemCreatesBinding() {
|
||||
String code = pairingService.generate("dev_p1");
|
||||
DeviceBinding binding = pairingService.redeem(code, "usr_p1");
|
||||
assertNotNull(binding);
|
||||
assertTrue(bindingService.isBound("dev_p1", "usr_p1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeemIsOneTimeUse() {
|
||||
String code = pairingService.generate("dev_p2");
|
||||
pairingService.redeem(code, "usr_p2");
|
||||
assertThrows(AuthException.class, () -> pairingService.redeem(code, "usr_other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeemUnknownCodeFails() {
|
||||
assertThrows(AuthException.class, () -> pairingService.redeem("ZZZZZZZZ", "usr_x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeemIsCaseInsensitive() {
|
||||
String code = pairingService.generate("dev_p3");
|
||||
// 大写后仍能兑换(生成码本身为大写 base32 字符集,这里再确认大小写归一)
|
||||
DeviceBinding binding = pairingService.redeem(code.toUpperCase(), "usr_p3");
|
||||
assertNotNull(binding);
|
||||
}
|
||||
|
||||
@Test
|
||||
void turnCredentialsIssuedWhenEnabled() {
|
||||
SecurityProperties props = new SecurityProperties();
|
||||
props.getTurn().setEnabled(true);
|
||||
props.getTurn().setSharedSecret("test-turn-secret");
|
||||
props.getTurn().setUrls("turn:turn.ttstd.com:3478?transport=udp");
|
||||
props.getTurn().setTtlSeconds(3600);
|
||||
TurnCredentialService turn = new TurnCredentialService(props);
|
||||
|
||||
Map<String, Object> creds = turn.issue("controller:usr_1");
|
||||
assertTrue(turn.isEnabled());
|
||||
List<?> ice = (List<?>) creds.get("iceServers");
|
||||
assertNotNull(ice);
|
||||
assertEquals(1, ice.size());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> server = (Map<String, String>) ice.get(0);
|
||||
assertTrue(server.containsKey("username"));
|
||||
assertTrue(server.containsKey("credential"));
|
||||
assertTrue(server.get("urls").startsWith("turn:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void turnDisabledThrows() {
|
||||
SecurityProperties props = new SecurityProperties();
|
||||
props.getTurn().setEnabled(false);
|
||||
TurnCredentialService turn = new TurnCredentialService(props);
|
||||
assertFalse(turn.isEnabled());
|
||||
assertThrows(AuthException.class, () -> turn.issue("scope"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user