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); } } }