From 68d86feea4e1ad08afb5a2ba06c73ba67856d102 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Mon, 9 Nov 2026 19:03:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(webrtc):=20=E4=BF=A1=E4=BB=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8C=96=E5=B9=B6=E5=BC=BA=E5=8C=96=E6=8F=A1=E6=89=8B?= =?UTF-8?q?=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设备握手改用已登记公钥验签并加 nonce 防重放,client 拒绝刷新令牌 - 管理员凭一次性票据接入,TURN 凭据改由服务端按需签发 - 会话绑定 sessionId,协商超时/断线统一结束,避免跨会话串流 - 绑定手机号时禁止覆盖已登记公钥,challenge 强制匹配 SN --- sql/ttstd/menu_device_remote_control.sql | 22 ++ .../device/controller/MobileController.java | 31 +- .../AdminRemoteControlController.java | 57 +++ .../support/webrtc/dto/SignalMessage.java | 2 + .../webrtc/session/SessionManager.java | 23 +- .../session/SignalHandshakeInterceptor.java | 99 ++++-- .../session/SignalWebSocketHandler.java | 324 +++++++++++------- .../webrtc/session/TurnCredentials.java | 44 +++ .../session/RemoteControlSecurityTest.java | 149 ++++++++ 9 files changed, 567 insertions(+), 184 deletions(-) create mode 100644 sql/ttstd/menu_device_remote_control.sql create mode 100644 src/main/java/com/youlai/boot/support/webrtc/controller/AdminRemoteControlController.java create mode 100644 src/main/java/com/youlai/boot/support/webrtc/session/TurnCredentials.java create mode 100644 src/test/java/com/youlai/boot/support/webrtc/session/RemoteControlSecurityTest.java diff --git a/sql/ttstd/menu_device_remote_control.sql b/sql/ttstd/menu_device_remote_control.sql new file mode 100644 index 00000000..c6435f7a --- /dev/null +++ b/sql/ttstd/menu_device_remote_control.sql @@ -0,0 +1,22 @@ +-- Add the remote assistance page below the existing SN management directory. +SET @remote_parent = (SELECT parent_id FROM sys_menu WHERE component = 'devices/sn/index' LIMIT 1); +SET @remote_tree = (SELECT CONCAT(tree_path, ',', id) FROM sys_menu WHERE id = @remote_parent); + +INSERT INTO sys_menu (parent_id, tree_path, name, type, route_name, route_path, component, + always_show, keep_alive, visible, sort, icon, create_time, update_time) +SELECT @remote_parent, @remote_tree, '设备远程协助', 'M', 'DeviceRemoteControl', 'remote-control', + 'devices/remote-control/index', 0, 0, 1, 4, 'device', NOW(), NOW() +WHERE @remote_parent IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE component = 'devices/remote-control/index'); + +SET @remote_menu = (SELECT id FROM sys_menu WHERE component = 'devices/remote-control/index' LIMIT 1); +SET @remote_perm = CONCAT('sys', ':', 'device', ':', 'remote-control'); + +INSERT INTO sys_menu (parent_id, tree_path, name, type, perm, visible, sort, create_time, update_time) +SELECT @remote_menu, CONCAT(@remote_tree, ',', @remote_menu), '发起远程协助', 'B', @remote_perm, 1, 1, NOW(), NOW() +WHERE @remote_menu IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id = @remote_menu AND perm = @remote_perm); + +INSERT IGNORE INTO sys_role_menu (role_id, menu_id) +SELECT r.id, m.id FROM sys_role r CROSS JOIN sys_menu m +WHERE r.code = 'ADMIN' AND (m.id = @remote_menu OR (m.parent_id = @remote_menu AND m.perm = @remote_perm)); diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java index af5ce21e..595cd984 100644 --- a/src/main/java/com/youlai/boot/device/controller/MobileController.java +++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java @@ -85,7 +85,7 @@ public class MobileController { @Operation(summary = "设备确认绑定手机号(绑定即注册)", description = "设备端同意家属绑定请求后调用。接口豁免标准签名过滤,改用 PoP 验签:\n" + "1) 用请求内公钥验证设备对 challenge(sn:ts:nonce) 的 ECDSA 签名;\n" + - "2) 自动注册设备(SN ↔ 公钥,不存在则新建,已存在则更新公钥);\n" + + "2) 自动注册设备(SN ↔ 公钥,不存在则新建,已登记公钥的设备必须使用原密钥);\n" + "3) 写入 sys_sn.snMobile 完成绑定,并向设备回推绑定成功。\n" + "设备首次绑定前可能尚未注册(无公钥),故不能走标准 ECDSA 验签,而是与注册接口相同的 PoP 方式。") @PostMapping("/bind_phone") @@ -112,7 +112,7 @@ public class MobileController { || signature == null || signature.trim().isEmpty()) { return Result.failed("设备公钥/挑战值/签名不能为空"); } - if (!verifyRegisterChallenge(publicKey, challenge, signature)) { + if (!verifyRegisterChallenge(sn, publicKey, challenge, signature)) { return Result.failed(ResultCode.MOBILE_SIGN_INVALID, "设备签名验证失败"); } @@ -131,7 +131,7 @@ public class MobileController { } if (StringUtils.hasText(device.getPublicKey()) && !device.getPublicKey().equals(publicKey)) { - logger.info("设备公钥变更(重新授权), sn: {}, phone: {}", sn, phone.trim()); + return Result.failed("设备公钥与已登记公钥不一致,请联系管理员重新登记"); } } @@ -148,12 +148,21 @@ public class MobileController { return Result.failed("设备注册失败"); } } else { - device.setPublicKey(publicKey); - if (model != null && !model.trim().isEmpty()) { - device.setSnModel(model); - } - device.setSnMobile(phone.trim()); - deviceService.updateById(device); + boolean updated = deviceService.lambdaUpdate() + .eq(SnDeviceInfo::getId, device.getId()) + .and(keys -> keys.isNull(SnDeviceInfo::getPublicKey) + .or().eq(SnDeviceInfo::getPublicKey, "") + .or().eq(SnDeviceInfo::getPublicKey, publicKey)) + .and(phones -> phones.isNull(SnDeviceInfo::getSnMobile) + .or().eq(SnDeviceInfo::getSnMobile, "") + .or().eq(SnDeviceInfo::getSnMobile, phone.trim())) + .and(status -> status.isNull(SnDeviceInfo::getStatus) + .or().ne(SnDeviceInfo::getStatus, 0)) + .set(SnDeviceInfo::getPublicKey, publicKey) + .set(SnDeviceInfo::getSnMobile, phone.trim()) + .set(StringUtils.hasText(model), SnDeviceInfo::getSnModel, model) + .update(); + if (!updated) return Result.failed("设备登记状态已变化,请重试"); } logger.info("设备确认绑定成功(绑定即注册), sn: {}, phone: {}", sn, phone.trim()); @@ -187,11 +196,11 @@ public class MobileController { * * PoP 验签本身已证明「请求者持有该公钥对应的私钥」。 */ - private boolean verifyRegisterChallenge(String publicKey, String challenge, String signature) { + private boolean verifyRegisterChallenge(String sn, String publicKey, String challenge, String signature) { try { // 解析 challenge:sn:ts:nonce(格式校验,时间戳仅作为签名原文的一部分,不校验时效) String[] parts = challenge.split(":", 3); - if (parts.length != 3) { + if (parts.length != 3 || !sn.equals(parts[0])) { return false; } Long.parseLong(parts[1]); diff --git a/src/main/java/com/youlai/boot/support/webrtc/controller/AdminRemoteControlController.java b/src/main/java/com/youlai/boot/support/webrtc/controller/AdminRemoteControlController.java new file mode 100644 index 00000000..47793f66 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/webrtc/controller/AdminRemoteControlController.java @@ -0,0 +1,57 @@ +package com.youlai.boot.support.webrtc.controller; + +import com.youlai.boot.common.result.Result; +import com.youlai.boot.device.model.entity.SnDeviceInfo; +import com.youlai.boot.device.service.DeviceService; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.web.ratelimit.RateLimit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.UUID; + +@Slf4j +@RestController +@RequestMapping("/api/v1/device/remote-control") +@RequiredArgsConstructor +public class AdminRemoteControlController { + private final DeviceService devices; + private final StringRedisTemplate redis; + private final TokenManager tokens; + + @PostMapping("/ticket") + @RateLimit(limit = 10, window = 60) + @PreAuthorize("@ss.hasPerm('sys:device:remote-control')") + public Result ticket(@RequestParam String sn, @RequestHeader("Authorization") String authorization) { + var user = SecurityUtils.getUser().orElse(null); + if (user == null || user.getUserId() == null || !"admin".equals(user.getClientType()) + || !authorization.startsWith("Bearer ") || sn.isBlank() + || sn.length() > 128 || sn.contains("\n")) { + return Result.failed("远程控制请求无效"); + } + String token = authorization.substring(7); + if (!tokens.validateToken(token) || tokens.validateRefreshToken(token)) { + return Result.failed("需要有效的管理员访问令牌"); + } + SnDeviceInfo device = devices.lambdaQuery().eq(SnDeviceInfo::getSerialno, sn).one(); + if (device == null || Integer.valueOf(0).equals(device.getStatus())) { + return Result.failed("设备不存在或已停用"); + } + + String ticket = UUID.randomUUID().toString(); + String grant = String.join("\n", user.getUserId().toString(), + user.getUsername().replace("\n", " "), sn, token); + redis.opsForValue().set("webrtc:admin-ticket:" + ticket, grant, Duration.ofSeconds(30)); + log.info("[webrtc] 管理员 {} 请求远程协助设备 {}", user.getUserId(), sn); + return Result.success(ticket); + } +} diff --git a/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java b/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java index 4248fe44..223dd8dd 100644 --- a/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java +++ b/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java @@ -24,6 +24,8 @@ public class SignalMessage { private String toDeviceId; + private String sessionId; + /** CONTROLLER(控制端=client 家属端)/ CONTROLLED(被控端=设备)。由服务器识别身份后覆盖。 */ private String deviceType; diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java b/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java index a2b1f05c..e4292511 100644 --- a/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java +++ b/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java @@ -46,21 +46,16 @@ public class SessionManager { log.info("[webrtc] 会话注册: {} ({}), 当前在线 {}", deviceId, session.getId(), sessions.size()); } - public void unregister(String deviceId) { - sessions.remove(deviceId); - log.info("[webrtc] 会话注销: {}, 当前在线 {}", deviceId, sessions.size()); + public void unregister(String key, WebSocketSession session) { + sessions.remove(key, session); } - public boolean isOnline(String deviceId) { - WebSocketSession s = sessions.get(deviceId); - return s != null && s.isOpen(); + public WebSocketSession get(String key) { + WebSocketSession session = sessions.get(key); + return session != null && session.isOpen() ? session : null; } - /** - * 向指定标识转发消息。目标离线时返回 false。 - */ - public boolean sendTo(String deviceId, SignalMessage message) { - WebSocketSession target = sessions.get(deviceId); + public boolean sendTo(WebSocketSession target, SignalMessage message) { if (target == null || !target.isOpen()) { return false; } @@ -70,15 +65,11 @@ public class SessionManager { } return true; } catch (IOException e) { - log.error("[webrtc] 转发失败 to={}: {}", deviceId, e.getMessage()); + log.debug("[webrtc] 转发失败 session={}: {}", target.getId(), e.getMessage()); return false; } } - public Map getSessions() { - return sessions; - } - private String toJson(SignalMessage message) { try { return objectMapper.writeValueAsString(message); diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java b/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java index d043c527..c8bdea22 100644 --- a/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java +++ b/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java @@ -1,7 +1,11 @@ package com.youlai.boot.support.webrtc.session; import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; +import com.youlai.boot.device.model.entity.SnDeviceInfo; +import com.youlai.boot.device.service.DeviceService; +import com.youlai.boot.framework.security.signature.DeviceSignatureVerifier; +import org.springframework.data.redis.core.StringRedisTemplate; +import java.time.Duration; import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.token.TokenManager; import lombok.RequiredArgsConstructor; @@ -13,23 +17,9 @@ import org.springframework.stereotype.Component; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeInterceptor; -import java.nio.charset.StandardCharsets; import java.util.Map; -import java.util.SortedMap; -import java.util.TreeMap; -/** - * WebRTC 信令握手鉴权拦截器(识别双身份)。 - *

- * 遵循平台原有用户逻辑,区分两端: - *

    - *
  • 设备端(CONTROLLED):携带 {@code X-Device-SN/X-Nonce/X-Timestamp/X-Sign}, - * 与 HTTP 层 {@code MobileApiSignatureFilter} 相同的 SHA-256 验签 + 2 分钟时间戳容差。
  • - *
  • 家属端 client(CONTROLLER):携带 {@code Authorization: Bearer }, - * 解析后要求 {@code clientType == client}(admin 令牌不可连入)。
  • - *
- * 两端互斥:既带设备签名又带 Bearer 时优先按设备端处理;都不带则拒绝。 - */ +/** 使用设备公钥签名、家属 JWT 或管理端一次性票据鉴权。 */ @Slf4j @Component @RequiredArgsConstructor @@ -53,7 +43,14 @@ public class SignalHandshakeInterceptor implements HandshakeInterceptor { /** 会话属性键:client 请求控制的目标设备 SN */ public static final String ATTR_TARGET_SN = "targetSn"; + public static final String ATTR_USER_ID = "userId"; + public static final String ATTR_ACCESS_TOKEN = "accessToken"; + public static final String ATTR_ADMIN = "admin"; + private final TokenManager tokenManager; + private final DeviceService deviceService; + private final DeviceSignatureVerifier verifier; + private final StringRedisTemplate redis; @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, @@ -73,6 +70,12 @@ public class SignalHandshakeInterceptor implements HandshakeInterceptor { return false; } + String query = request.getURI().getRawQuery(); + String ticket = query == null ? null : extractQueryParam(query, "ticket"); + if (ticket != null) { + return authenticateAdmin(ticket, attributes); + } + // 2. 家属端 client JWT 鉴权 String authHeader = request.getHeaders().getFirst("Authorization"); if (StrUtil.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) { @@ -86,7 +89,7 @@ public class SignalHandshakeInterceptor implements HandshakeInterceptor { private boolean authenticateClient(String token, ServerHttpRequest request, Map attributes) { try { - if (!tokenManager.validateToken(token)) { + if (!tokenManager.validateToken(token) || tokenManager.validateRefreshToken(token)) { log.warn("[webrtc] client 令牌无效"); return false; } @@ -111,7 +114,11 @@ public class SignalHandshakeInterceptor implements HandshakeInterceptor { if (StrUtil.isNotBlank(targetSn)) { attributes.put(ATTR_TARGET_SN, targetSn); } - log.info("[webrtc] client 握手通过: user={}, targetSn={}", clientName, targetSn); + if (StrUtil.isBlank(targetSn) || details.getUserId() == null) { + return false; + } + attributes.put(ATTR_USER_ID, details.getUserId()); + attributes.put(ATTR_ACCESS_TOKEN, token); return true; } catch (Exception e) { log.error("[webrtc] client 鉴权异常: {}", e.getMessage()); @@ -119,6 +126,33 @@ public class SignalHandshakeInterceptor implements HandshakeInterceptor { } } + private boolean authenticateAdmin(String ticket, Map attributes) { + if (!ticket.matches("[0-9a-f-]{36}")) { + return false; + } + try { + String grant = redis.opsForValue().getAndDelete("webrtc:admin-ticket:" + ticket); + if (grant == null) { + return false; + } + String[] parts = grant.split("\n", 4); + if (parts.length != 4 || !tokenManager.validateToken(parts[3])) { + return false; + } + attributes.put(ATTR_DEVICE_ID, "admin:" + parts[0]); + attributes.put(ATTR_USER_ID, Long.valueOf(parts[0])); + attributes.put(ATTR_CLIENT_NAME, parts[1]); + attributes.put(ATTR_TARGET_SN, parts[2]); + attributes.put(ATTR_ACCESS_TOKEN, parts[3]); + attributes.put(ATTR_DEVICE_TYPE, "CONTROLLER"); + attributes.put(ATTR_ADMIN, true); + return true; + } catch (Exception e) { + log.warn("[webrtc] 管理端票据校验失败", e); + return false; + } + } + private boolean verifyDeviceSignature(ServerHttpRequest request, String sn) { String nonce = request.getHeaders().getFirst(HEADER_NONCE); String timestampStr = request.getHeaders().getFirst(HEADER_TIMESTAMP); @@ -132,18 +166,27 @@ public class SignalHandshakeInterceptor implements HandshakeInterceptor { } catch (NumberFormatException e) { return false; } - if (Math.abs(System.currentTimeMillis() - timestamp) > SIGN_VALID_DURATION) { + long now = System.currentTimeMillis(); + if (timestamp < now - SIGN_VALID_DURATION || timestamp > now + SIGN_VALID_DURATION + || nonce.length() > 128 || sn.length() > 128 || sign.length() > 256 + || nonce.contains("|") || sn.contains("|")) { + return false; + } + try { + SnDeviceInfo device = deviceService.lambdaQuery().eq(SnDeviceInfo::getSerialno, sn).one(); + if (device == null || Integer.valueOf(0).equals(device.getStatus()) + || StrUtil.isBlank(device.getPublicKey())) { + return false; + } + String canonical = "GET|/ws/signal|" + sn + "|" + timestampStr + "|" + nonce; + return verifier.verify(device.getPublicKey(), canonical, sign) + && Boolean.TRUE.equals(redis.opsForValue().setIfAbsent( + "webrtc:nonce:" + sn + ":" + nonce, "1", + Duration.ofMillis(2 * SIGN_VALID_DURATION))); + } catch (Exception e) { + log.warn("[webrtc] 设备验签或防重放检查失败", e); return false; } - SortedMap params = new TreeMap<>(); - params.put(HEADER_DEVICE_ID, sn); - params.put(HEADER_NONCE, nonce); - params.put(HEADER_TIMESTAMP, timestampStr); - StringBuilder sb = new StringBuilder(); - params.forEach((k, v) -> sb.append(k).append("=").append(v).append("&")); - sb.setLength(sb.length() - 1); - String expected = DigestUtil.sha256Hex(sb.toString()); - return sign.equals(expected); } private String extractQueryParam(String query, String key) { diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java b/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java index 7acdc446..c82e8291 100644 --- a/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java +++ b/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java @@ -1,183 +1,249 @@ package com.youlai.boot.support.webrtc.session; -import cn.hutool.core.util.StrUtil; import com.youlai.boot.client.model.entity.ClientUser; +import com.youlai.boot.common.constant.SystemConstants; +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import org.springframework.util.PatternMatchUtils; 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.token.TokenManager; import com.youlai.boot.support.webrtc.dto.SignalMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import tools.jackson.databind.ObjectMapper; import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.UUID; + +import static com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor.*; -/** - * WebRTC 信令 WebSocket 处理器(消息转发)。 - *

- * 职责: - *

    - *
  • 连接建立后,按握手身份注册会话并下发 {@code REGISTER_SUCCESS}。
  • - *
  • 转发 OFFER/ANSWER/ICE_CANDIDATE 到目标标识;服务端强制覆盖 deviceType,防伪造。
  • - *
  • client 控制端发起 OFFER 前,校验其已绑定目标设备(复用 snMobile==mobile 绑定关系)。
  • - *
- */ @Slf4j @Component @RequiredArgsConstructor public class SignalWebSocketHandler extends TextWebSocketHandler { + private static final long SESSION_MS = Duration.ofMinutes(15).toMillis(); + private static final long NEGOTIATION_MS = Duration.ofMinutes(2).toMillis(); - private final SessionManager sessionManager; - private final DeviceService deviceService; - private final ClientUserService clientUserService; - private final ObjectMapper objectMapper; + private final SessionManager sessions; + private final DeviceService devices; + private final ClientUserService users; + private final TokenManager tokens; + private final UserAuthenticationPort adminUsers; + private final PermissionPort permissions; + private final TurnCredentials turnCredentials; + private final ObjectMapper json; - @Override - public void afterConnectionEstablished(WebSocketSession session) { - Map attrs = session.getAttributes(); - String deviceId = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_ID); - String deviceType = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_TYPE); - if (StrUtil.isBlank(deviceId)) { - closeSession(session, CloseStatus.POLICY_VIOLATION, "缺失身份"); - return; + // Each authenticated socket participates in at most one control session. + private final Map active = new HashMap<>(); + + private static final class ControlSession { + final String id = UUID.randomUUID().toString(); + final WebSocketSession controller; + final WebSocketSession device; + final long started = System.currentTimeMillis(); + boolean answered; + + ControlSession(WebSocketSession controller, WebSocketSession device) { + this.controller = controller; + this.device = device; } - sessionManager.register(deviceId, session); - log.info("[webrtc] 连接建立: {} ({})", deviceId, deviceType); - - // 下发 REGISTER_SUCCESS - SignalMessage reg = new SignalMessage(); - reg.setType("REGISTER_SUCCESS"); - reg.setFromDeviceId(deviceId); - reg.setToDeviceId(deviceId); - reg.setDeviceType(deviceType); - sendToSelf(session, reg); } @Override - protected void handleTextMessage(WebSocketSession session, TextMessage message) { - Map attrs = session.getAttributes(); - String fromDeviceId = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_ID); - String deviceType = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_TYPE); + public void afterConnectionEstablished(WebSocketSession socket) { + if (controller(socket) && !authorized(socket, attr(socket, ATTR_TARGET_SN))) { + close(socket, CloseStatus.POLICY_VIOLATION); + return; + } + socket.setTextMessageSizeLimit(65536); + sessions.register(key(socket), socket); + SignalMessage registered = serverMessage("REGISTER_SUCCESS", socket, null); + registered.setFromDeviceId(attr(socket, ATTR_DEVICE_ID)); + registered.setDeviceType(attr(socket, ATTR_DEVICE_TYPE)); + if (controller(socket)) registered.setPayload(turnCredentials.issueJson()); + sessions.sendTo(socket, registered); + } - SignalMessage msg; + @Override + protected synchronized void handleTextMessage(WebSocketSession sender, TextMessage text) { + if (sessions.get(key(sender)) != sender || text.getPayloadLength() > 65536) return; + SignalMessage message; try { - msg = objectMapper.readValue(message.getPayload(), SignalMessage.class); + message = json.readValue(text.getPayload(), SignalMessage.class); } catch (Exception e) { - log.warn("[webrtc] 信令消息解析失败: {}", e.getMessage()); - return; - } - if (msg == null || StrUtil.isBlank(msg.getType())) { return; } + if (message == null || message.getType() == null) return; + String type = message.getType().toUpperCase(Locale.ROOT); + ControlSession call = active.get(sender); - // 服务端强制覆盖身份,防伪造 - msg.setFromDeviceId(fromDeviceId); - msg.setDeviceType(deviceType); - - String type = msg.getType().toUpperCase(); - String toDeviceId = msg.getToDeviceId(); - log.debug("[webrtc] 收到并处理: from={}, to={}, type={}, deviceType={}", fromDeviceId, toDeviceId, type, deviceType); - - // client 控制端发起 OFFER 前,校验绑定目标设备 - if ("CONTROLLER".equals(deviceType) && "OFFER".equals(type)) { - if (StrUtil.isBlank(toDeviceId)) { - sendToSelf(session, rejected(toDeviceId, "缺少目标设备")); + if ("OFFER".equals(type)) { + if (!controller(sender) || call != null) return; + String sn = attr(sender, ATTR_TARGET_SN); + if (!sn.equals(message.getToDeviceId()) || !authorized(sender, sn)) { + sessions.sendTo(sender, serverMessage("CONNECTION_REJECTED", sender, null)); return; } - String clientName = fromDeviceId; - if (!isBound(clientName, toDeviceId)) { - log.warn("[webrtc] client {} 未绑定设备 {},拒绝 OFFER", clientName, toDeviceId); - sendToSelf(session, rejected(toDeviceId, "当前用户未绑定该设备")); + WebSocketSession device = sessions.get("device:" + sn); + if (device == null) { + sessions.sendTo(sender, serverMessage("TARGET_OFFLINE", sender, null)); return; } - } - - // 转发:目标离线时回送 TARGET_OFFLINE - boolean delivered = sessionManager.sendTo(toDeviceId, msg); - log.info("[webrtc] 转发结果: from={}, to={}, type={}, delivered={}, 当前在线设备={}", - fromDeviceId, toDeviceId, type, delivered, sessionManager.getSessions().keySet()); - if (!delivered) { - log.warn("[webrtc] 目标离线或不存在: to={}, type={}", toDeviceId, type); - SignalMessage offline = new SignalMessage(); - offline.setType("TARGET_OFFLINE"); - offline.setFromDeviceId("server"); - offline.setToDeviceId(fromDeviceId); - offline.setDeviceType(deviceType); - sendToSelf(session, offline); - } - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - Map attrs = session.getAttributes(); - String deviceId = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_ID); - if (StrUtil.isNotBlank(deviceId)) { - sessionManager.unregister(deviceId); - } - log.info("[webrtc] 连接关闭: deviceId={}, sessionId={}, code={}, reason={}, attrs={}", - deviceId, session.getId(), status.getCode(), status.getReason(), attrs); - } - - @Override - public void handleTransportError(WebSocketSession session, Throwable exception) { - log.error("[webrtc] 传输错误: sessionId={}, error={}", session.getId(), exception.getMessage()); - try { - session.close(CloseStatus.SERVER_ERROR); - } catch (IOException ignored) { - } - } - - /** 校验 client 用户(app_user.mobile)是否已绑定目标设备(sys_sn.snMobile)。 */ - private boolean isBound(String clientName, String sn) { - try { - ClientUser user = clientUserService.getOne( - new LambdaQueryWrapper().eq(ClientUser::getUsername, clientName).last("limit 1") - ); - if (user == null || StrUtil.isBlank(user.getMobile())) { - return false; + if (active.containsKey(device)) { + sessions.sendTo(sender, serverMessage("CONNECTION_REJECTED", sender, null)); + return; } - SnDeviceInfo device = deviceService.getOne( - new LambdaQueryWrapper().eq(SnDeviceInfo::getSerialno, sn) - ); - return device != null && user.getMobile().equals(device.getSnMobile()); + try { + var offer = json.readTree(message.getPayload()); + if (!offer.path("sdp").isString()) return; + // Rebuild the payload using only authenticated display identity. + var payload = json.createObjectNode(); + payload.put("sdp", offer.path("sdp").asText()); + payload.put("username", attr(sender, ATTR_CLIENT_NAME)); + payload.put("adminDirectControl", Boolean.TRUE.equals(sender.getAttributes().get(ATTR_ADMIN))); + payload.put("turn", turnCredentials.issueJson()); + message.setPayload(json.writeValueAsString(payload)); + } catch (Exception e) { + return; + } + call = new ControlSession(sender, device); + active.put(sender, call); + active.put(device, call); + if (!forward(sender, device, message, call, type)) end(call); + return; + } + + if (call == null) return; + WebSocketSession receiver = sender == call.controller ? call.device : call.controller; + if (!attr(receiver, ATTR_DEVICE_ID).equals(message.getToDeviceId()) + || !call.id.equals(message.getSessionId())) { + return; + } + switch (type) { + case "ANSWER" -> { + if (sender != call.device || call.answered) return; + call.answered = true; + } + case "ICE_CANDIDATE" -> { } + case "SESSION_END" -> { + end(call); + return; + } + case "CONNECTION_REJECTED" -> { + if (sender != call.device || call.answered) return; + forward(sender, receiver, message, call, type); + end(call); + return; + } + default -> { return; } + } + if (!forward(sender, receiver, message, call, type)) end(call); + } + + private boolean forward(WebSocketSession sender, WebSocketSession receiver, SignalMessage message, + ControlSession call, String type) { + message.setType(type); + message.setFromDeviceId(attr(sender, ATTR_DEVICE_ID)); + message.setDeviceType(attr(sender, ATTR_DEVICE_TYPE)); + message.setSessionId(call.id); + return sessions.sendTo(receiver, message); + } + + @Override + public synchronized void afterConnectionClosed(WebSocketSession socket, CloseStatus status) { + sessions.unregister(key(socket), socket); + ControlSession call = active.get(socket); + if (call != null) end(call); + } + + @Override + public void handleTransportError(WebSocketSession socket, Throwable error) { + close(socket, CloseStatus.SERVER_ERROR); + } + + @Scheduled(fixedDelay = 5000) + public synchronized void expireSessions() { + long now = System.currentTimeMillis(); + for (ControlSession call : active.values().stream().distinct().toList()) { + long ttl = call.answered ? SESSION_MS : NEGOTIATION_MS; + String sn = attr(call.device, ATTR_DEVICE_ID); + if (now - call.started >= ttl || sessions.get(key(call.controller)) != call.controller + || sessions.get(key(call.device)) != call.device || !authorized(call.controller, sn)) { + end(call); + } + } + } + + private void end(ControlSession call) { + active.remove(call.controller, call); + active.remove(call.device, call); + sessions.sendTo(call.device, serverMessage("SESSION_ENDED", call.device, call.id)); + sessions.sendTo(call.controller, serverMessage("SESSION_ENDED", call.controller, call.id)); + } + + private boolean authorized(WebSocketSession controller, String sn) { + try { + if (sn == null || !tokens.validateToken(attr(controller, ATTR_ACCESS_TOKEN))) return false; + SnDeviceInfo device = devices.lambdaQuery().eq(SnDeviceInfo::getSerialno, sn).one(); + if (device == null || Integer.valueOf(0).equals(device.getStatus())) return false; + if (Boolean.TRUE.equals(controller.getAttributes().get(ATTR_ADMIN))) { + var admin = adminUsers.getAuthInfoByUsername(attr(controller, ATTR_CLIENT_NAME)); + if (admin == null || !Integer.valueOf(1).equals(admin.getStatus()) + || !admin.getUserId().equals(controller.getAttributes().get(ATTR_USER_ID)) + || admin.getRoles() == null || admin.getRoles().isEmpty()) return false; + return admin.getRoles().contains(SystemConstants.ROOT_ROLE_CODE) + || permissions.getRolePerms(admin.getRoles()).stream() + .anyMatch(perm -> PatternMatchUtils.simpleMatch(perm, "sys:device:remote-control")); + } + ClientUser user = users.getById((Long) controller.getAttributes().get(ATTR_USER_ID)); + return user != null && Integer.valueOf(1).equals(user.getStatus()) && user.getMobile() != null && !user.getMobile().isBlank() + && user.getMobile().equals(device.getSnMobile()); } catch (Exception e) { - log.error("[webrtc] 绑定校验异常: {}", e.getMessage()); + log.warn("[webrtc] 授权检查失败", e); return false; } } - private SignalMessage rejected(String toDeviceId, String reason) { - SignalMessage msg = new SignalMessage(); - msg.setType("CONNECTION_REJECTED"); - msg.setFromDeviceId("server"); - msg.setToDeviceId(toDeviceId); - msg.setDeviceType("CONTROLLER"); - msg.setPayload("{\"reason\":\"" + reason + "\"}"); - return msg; + private SignalMessage serverMessage(String type, WebSocketSession receiver, String sessionId) { + SignalMessage message = new SignalMessage(); + message.setType(type); + message.setFromDeviceId("server"); + message.setToDeviceId(attr(receiver, ATTR_DEVICE_ID)); + message.setSessionId(sessionId); + return message; } - private void sendToSelf(WebSocketSession session, SignalMessage msg) { - try { - synchronized (session) { - session.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); - } - } catch (Exception e) { - log.error("[webrtc] 回送失败: {}", e.getMessage()); - } + private static boolean controller(WebSocketSession socket) { + return "CONTROLLER".equals(attr(socket, ATTR_DEVICE_TYPE)); } - private void closeSession(WebSocketSession session, CloseStatus status, String reason) { + private static String key(WebSocketSession socket) { + if (!controller(socket)) return "device:" + attr(socket, ATTR_DEVICE_ID); + String type = Boolean.TRUE.equals(socket.getAttributes().get(ATTR_ADMIN)) ? "admin:" : "family:"; + return type + socket.getAttributes().get(ATTR_USER_ID); + } + + private static String attr(WebSocketSession socket, String name) { + return (String) socket.getAttributes().get(name); + } + + private void close(WebSocketSession socket, CloseStatus status) { try { - session.close(status.withReason(reason)); + socket.close(status); } catch (IOException e) { - log.warn("[webrtc] 关闭异常: {}", e.getMessage()); + log.debug("[webrtc] 关闭信令连接失败", e); } } } diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/TurnCredentials.java b/src/main/java/com/youlai/boot/support/webrtc/session/TurnCredentials.java new file mode 100644 index 00000000..66eeb92b --- /dev/null +++ b/src/main/java/com/youlai/boot/support/webrtc/session/TurnCredentials.java @@ -0,0 +1,44 @@ +package com.youlai.boot.support.webrtc.session; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class TurnCredentials { + private final ObjectMapper json; + + @Value("${webrtc.turn.urls:}") + private String urls; + + @Value("${webrtc.turn.secret:}") + private String secret; + + public String issueJson() { + if (urls.isBlank() || secret.isBlank()) return "{}"; + var relayUrls = Arrays.stream(urls.split(",")) + .map(String::trim).filter(url -> url.startsWith("turn:") || url.startsWith("turns:")).toList(); + if (relayUrls.isEmpty()) return "{}"; + // coturn REST API credentials. Keep the shared secret exclusively on the server. + String username = Instant.now().plusSeconds(1200).getEpochSecond() + ":" + UUID.randomUUID(); + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + String credential = Base64.getEncoder().encodeToString(mac.doFinal(username.getBytes(StandardCharsets.UTF_8))); + return json.writeValueAsString(Map.of("urls", relayUrls, "username", username, "credential", credential)); + } catch (Exception e) { + throw new IllegalStateException("Cannot issue TURN credentials", e); + } + } +} diff --git a/src/test/java/com/youlai/boot/support/webrtc/session/RemoteControlSecurityTest.java b/src/test/java/com/youlai/boot/support/webrtc/session/RemoteControlSecurityTest.java new file mode 100644 index 00000000..26472621 --- /dev/null +++ b/src/test/java/com/youlai/boot/support/webrtc/session/RemoteControlSecurityTest.java @@ -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 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 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()); + } +}