feat(webrtc): 信令会话化并强化握手鉴权

- 设备握手改用已登记公钥验签并加 nonce 防重放,client 拒绝刷新令牌
- 管理员凭一次性票据接入,TURN 凭据改由服务端按需签发
- 会话绑定 sessionId,协商超时/断线统一结束,避免跨会话串流
- 绑定手机号时禁止覆盖已登记公钥,challenge 强制匹配 SN
This commit is contained in:
tongtong committed 2026-11-09 19:03:55 +08:00
1 parent 4dd7d98ce9
commit 68d86feea4
9 files changed
+567 -184

No files matched your search

@@ -0,0 +1,149 @@
package com.youlai.boot.support.webrtc.session;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.youlai.boot.client.model.entity.ClientUser;
import com.youlai.boot.client.service.ClientUserService;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.service.DeviceService;
import com.youlai.boot.framework.security.port.PermissionPort;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.framework.security.signature.DeviceSignatureVerifier;
import com.youlai.boot.framework.security.token.TokenManager;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import tools.jackson.databind.ObjectMapper;
import com.youlai.boot.support.webrtc.dto.SignalMessage;
import java.nio.charset.StandardCharsets;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
import java.time.Duration;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.ATTR_DEVICE_ID;
import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.ATTR_DEVICE_TYPE;
import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.ATTR_ACCESS_TOKEN;
import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.ATTR_CLIENT_NAME;
import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.ATTR_TARGET_SN;
import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.ATTR_USER_ID;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
class RemoteControlSecurityTest {
@Test
void deviceHandshakeRequiresRegisteredKeyAndConsumesNonceOnce() throws Exception {
DeviceService devices = mock(DeviceService.class);
StringRedisTemplate redis = mock(StringRedisTemplate.class, RETURNS_DEEP_STUBS);
DeviceSignatureVerifier verifier = new DeviceSignatureVerifier();
SignalHandshakeInterceptor interceptor =
new SignalHandshakeInterceptor(mock(TokenManager.class), devices, verifier, redis);
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
var keys = generator.generateKeyPair();
var device = new SnDeviceInfo();
device.setStatus(1);
device.setPublicKey(Base64.getEncoder().encodeToString(keys.getPublic().getEncoded()));
@SuppressWarnings("unchecked")
LambdaQueryChainWrapper<SnDeviceInfo> query = mock(LambdaQueryChainWrapper.class);
when(devices.lambdaQuery()).thenReturn(query);
when(query.eq(any(), eq("SN-1"))).thenReturn(query);
when(query.one()).thenReturn(device);
when(redis.opsForValue().setIfAbsent("webrtc:nonce:SN-1:nonce", "1", Duration.ofMinutes(4)))
.thenReturn(true, false);
var request = mock(ServerHttpRequest.class);
var response = mock(ServerHttpResponse.class);
var headers = new HttpHeaders();
when(request.getHeaders()).thenReturn(headers);
String timestamp = Long.toString(System.currentTimeMillis());
headers.set("X-Device-SN", "SN-1");
headers.set("X-Nonce", "nonce");
headers.set("X-Timestamp", timestamp);
headers.set("X-Sign", verifier.sha256Hex(
("X-Device-SN=SN-1&X-Nonce=nonce&X-Timestamp=" + timestamp).getBytes(StandardCharsets.UTF_8)));
assertFalse(interceptor.beforeHandshake(request, response, null, new HashMap<>()));
Signature signer = Signature.getInstance("SHA256withECDSA");
signer.initSign(keys.getPrivate());
signer.update(("GET|/ws/signal|SN-1|" + timestamp + "|nonce").getBytes(StandardCharsets.UTF_8));
headers.set("X-Sign", Base64.getEncoder().encodeToString(signer.sign()));
assertTrue(interceptor.beforeHandshake(request, response, null, new HashMap<>()));
assertFalse(interceptor.beforeHandshake(request, response, null, new HashMap<>()));
}
@Test
void deviceCannotSendOfferAndUnpairedSocketCannotSendIce() throws Exception {
SessionManager sessions = mock(SessionManager.class);
ObjectMapper json = new ObjectMapper();
var handler = new SignalWebSocketHandler(sessions, mock(DeviceService.class),
mock(ClientUserService.class), mock(TokenManager.class), mock(UserAuthenticationPort.class),
mock(PermissionPort.class), mock(TurnCredentials.class), json);
var socket = mock(WebSocketSession.class);
when(socket.getAttributes()).thenReturn(Map.of(ATTR_DEVICE_ID, "SN-1", ATTR_DEVICE_TYPE, "CONTROLLED"));
when(sessions.get("device:SN-1")).thenReturn(socket);
handler.handleTextMessage(socket, new TextMessage("""
{"type":"OFFER","toDeviceId":"SN-2","deviceType":"CONTROLLER"}
"""));
handler.handleTextMessage(socket, new TextMessage("""
{"type":"ICE_CANDIDATE","toDeviceId":"SN-2"}
"""));
verify(sessions, never()).sendTo(any(), any());
}
@Test
void boundFamilyCannotSetAdministratorDirectControl() throws Exception {
SessionManager sessions = mock(SessionManager.class);
DeviceService devices = mock(DeviceService.class);
ClientUserService users = mock(ClientUserService.class);
TokenManager tokens = mock(TokenManager.class);
ObjectMapper json = new ObjectMapper();
var handler = new SignalWebSocketHandler(sessions, devices, users, tokens,
mock(UserAuthenticationPort.class), mock(PermissionPort.class), mock(TurnCredentials.class), json);
var family = mock(WebSocketSession.class);
var device = mock(WebSocketSession.class);
when(family.getAttributes()).thenReturn(Map.of(
ATTR_DEVICE_ID, "family", ATTR_DEVICE_TYPE, "CONTROLLER", ATTR_CLIENT_NAME, "family",
ATTR_USER_ID, 1L, ATTR_TARGET_SN, "SN-1", ATTR_ACCESS_TOKEN, "family-access"));
when(sessions.get("family:1")).thenReturn(family);
when(sessions.get("device:SN-1")).thenReturn(device);
when(sessions.sendTo(eq(device), any())).thenReturn(true);
when(tokens.validateToken("family-access")).thenReturn(true);
var registeredDevice = new SnDeviceInfo();
registeredDevice.setStatus(1);
registeredDevice.setSnMobile("123");
@SuppressWarnings("unchecked")
LambdaQueryChainWrapper<SnDeviceInfo> query = mock(LambdaQueryChainWrapper.class);
when(devices.lambdaQuery()).thenReturn(query);
when(query.eq(any(), eq("SN-1"))).thenReturn(query);
when(query.one()).thenReturn(registeredDevice);
var familyUser = new ClientUser();
familyUser.setStatus(1);
familyUser.setMobile("123");
when(users.getById(1L)).thenReturn(familyUser);
var offer = new SignalMessage();
offer.setType("OFFER");
offer.setToDeviceId("SN-1");
offer.setPayload("{\"sdp\":\"offer\",\"adminDirectControl\":true}");
handler.handleTextMessage(family, new TextMessage(json.writeValueAsString(offer)));
var forwarded = ArgumentCaptor.forClass(SignalMessage.class);
verify(sessions).sendTo(eq(device), forwarded.capture());
assertFalse(json.readTree(forwarded.getValue().getPayload()).path("adminDirectControl").asBoolean());
}
}