feat(webrtc): 信令会话化并强化握手鉴权
- 设备握手改用已登记公钥验签并加 nonce 防重放,client 拒绝刷新令牌 - 管理员凭一次性票据接入,TURN 凭据改由服务端按需签发 - 会话绑定 sessionId,协商超时/断线统一结束,避免跨会话串流 - 绑定手机号时禁止覆盖已登记公钥,challenge 强制匹配 SN
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user