diff --git a/WebRTCControlled/app/build.gradle b/WebRTCControlled/app/build.gradle index 6d9d1f4..a34d083 100644 --- a/WebRTCControlled/app/build.gradle +++ b/WebRTCControlled/app/build.gradle @@ -21,6 +21,11 @@ android { targetSdk 34 versionCode 1 versionName "1.0" + + // 服务端地址(信令 wss 与 HTTP api 同源)。部署时通过 flavor / CI 注入真实值。 + buildConfigField "String", "API_BASE", "\"https://www.ttstd.com\"" + // 出厂预置共享密钥(用于 provision 签名)。正式发布必须替换并通过安全方式注入。 + buildConfigField "String", "DEVICE_PROVISION_SECRET", "\"dev-device-provision-secret-change-me\"" } compileOptions { @@ -155,4 +160,6 @@ dependencies { implementation 'com.google.code.gson:gson:2.10.1' // Protobuf(DataChannel 控制指令二进制) implementation 'com.google.protobuf:protobuf-java:3.25.1' + // 安全存储:加密 SharedPreferences(保存激活得到的 deviceSecret / deviceUid / accessToken) + implementation 'androidx.security:security-crypto:1.1.0-alpha06' } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java index 40543b9..aa53635 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/activity/main/MainActivity.java @@ -88,10 +88,9 @@ public class MainActivity extends BaseMvvmActivity startScreenSharing()); binding.btnStop.setOnClickListener(v -> stopScreenSharing()); @@ -198,7 +197,7 @@ public class MainActivity extends BaseMvvmActivity= Build.VERSION_CODES.O) { startForegroundService(serviceIntent); @@ -291,7 +290,13 @@ public class MainActivity extends BaseMvvmActivity { - // 信令服务器连接/重连成功:恢复运行状态显示。 + // 信令服务器连接/重连成功:恢复运行状态显示,回填服务端下发的设备ID。 + if (isBound && screenCaptureService != null) { + String uid = screenCaptureService.getDeviceUid(); + if (uid != null && !uid.isEmpty()) { + binding.etDeviceId.setText(uid); + } + } updateUI(true); }); } @@ -304,6 +309,14 @@ public class MainActivity extends BaseMvvmActivity { + binding.tvStatus.setText("状态: " + message); + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + }); + } + @Override public void onControllerDisconnected(String name) { runOnUiThread(() -> { @@ -317,7 +330,8 @@ public class MainActivity extends BaseMvvmActivity beginScreenCapture(serverUrl)); + } catch (Exception e) { + notifyActivationFailed(e.getMessage()); + } + }).start(); + } + + /** 通知 UI 激活失败(主线程调用)。 */ + private void notifyActivationFailed(String reason) { + Log.e(TAG, "激活失败: " + reason); + mainHandler.post(() -> { + if (stateListener != null) stateListener.onError("激活失败: " + reason); + // 激活失败不影响已建立的服务,但本次无法连接;停止前台服务避免空转。 + stopSelf(); + }); + } + + /** 激活成功后:延时启动屏幕采集并连接信令服务器。 */ + private void beginScreenCapture(String serverUrl) { + if (isShuttingDown) return; + this.deviceId = secretStore.getDeviceUid(); + final int finalCaptureWidth = currentCaptureWidth; + final int finalCaptureHeight = currentCaptureHeight; + final int finalFps = currentCaptureFps; + mainHandler.postDelayed(() -> { + if (!isShuttingDown) { + startScreenCapture(resultCode, resultDataIntent, serverUrl, deviceId, finalCaptureWidth, finalCaptureHeight, finalFps); + } + }, 200); + } + + /** 令牌失效(4001):后台线程重新换取 accessToken,成功后重连。 */ + private void refreshTokenAndReconnect(String serverUrl) { + new Thread(() -> { + try { + JsonObject tokenResp = apiClient.token(secretStore.getDeviceUid(), secretStore.getDeviceSecret()); + accessToken = tokenResp.has("accessToken") ? tokenResp.get("accessToken").getAsString() : null; + if (accessToken == null) { + mainHandler.post(() -> { + if (stateListener != null) stateListener.onError("令牌刷新失败,请重新激活"); + stopSelf(); + }); + return; + } + secretStore.saveAccessToken(accessToken); + mainHandler.post(() -> { + if (wsClient != null) wsClient.disconnect(); + // deviceId 不变,accessToken 已更新;重新连接会带上新令牌。 + wsClient = new WebSocketClient(serverUrl, accessToken, signalListener); + wsClient.connect(); + }); + } catch (Exception e) { + mainHandler.post(() -> { + if (stateListener != null) stateListener.onError("令牌刷新异常: " + e.getMessage()); + stopSelf(); + }); + } + }).start(); + } + /** * 根据当前应用权限/环境选择最合适的输入执行器: * 1. 系统签名应用优先使用隐藏 API 注入(支持精确触摸与任意按键); @@ -336,6 +438,11 @@ public class ScreenCaptureService extends Service { return screenCapturer != null && !isShuttingDown; } + /** 当前本机设备ID(服务端激活下发的 deviceUid)。 */ + public String getDeviceUid() { + return deviceId; + } + /** * 切换屏幕采集分辨率(本地 UI 调用)。 * @@ -608,6 +715,11 @@ public class ScreenCaptureService extends Service { * 远程控制端断开,参数为控制端名称(用户名或设备 ID)。 */ void onControllerDisconnected(String name); + + /** + * 错误/警告事件(激活失败、令牌失效、强制下线等),参数为可读描述。 + */ + void onError(String message); } private ServiceStateListener stateListener; @@ -666,9 +778,17 @@ public class ScreenCaptureService extends Service { // 初始化 EGL eglBase = EglBase.create(); - // 初始化 WebSocket - wsClient = new WebSocketClient(serverUrl, deviceId); - wsClient.setListener(new WebSocketClient.SignalListener() { + // 初始化 WebSocket(握手携带 Bearer accessToken) + this.signalListener = new WebSocketClient.SignalListener() { + @Override + public void onRegistered(String fromDeviceId) { + // 服务端下发本机 deviceId(deviceUid),与本地激活一致,仅做校验/日志。 + if (fromDeviceId != null && !fromDeviceId.isEmpty()) { + ScreenCaptureService.this.deviceId = fromDeviceId; + Log.i(TAG, "REGISTER_SUCCESS deviceId=" + fromDeviceId); + } + } + @Override public void onConnected() { Log.i(TAG, "Connected to signal server"); @@ -695,7 +815,25 @@ public class ScreenCaptureService extends Service { public void onMessage(SignalMessage message) { handleSignalMessage(message); } - }); + + @Override + public void onTokenExpired() { + // 关闭码 4001:重新换取令牌后重连。 + Log.w(TAG, "令牌失效(4001),重新换取并重连"); + refreshTokenAndReconnect(serverUrl); + } + + @Override + public void onForceLogout() { + // 关闭码 4003:强制下线,停止重连。 + Log.w(TAG, "强制下线(4003),停止服务"); + mainHandler.post(() -> { + if (stateListener != null) stateListener.onError("账号已在其他位置登录,已强制下线"); + stopSelf(); + }); + } + }; + wsClient = new WebSocketClient(serverUrl, accessToken, this.signalListener); wsClient.connect(); // 初始化 WebRTC diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/ApiClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/ApiClient.java new file mode 100644 index 0000000..48cbf2c --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/ApiClient.java @@ -0,0 +1,149 @@ +package com.ttstd.controlled.signaling; + +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.ttstd.controlled.BuildConfig; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.TimeUnit; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * 被控端 HTTP 客户端:对接安全信令服务器的激活(provision / token)与 TURN 接口。 + * + * 激活流程: + * - provision:用设备 SN + 随机 nonce + 时间戳 计算 HMAC,向服务端证明「出厂预置身份」, + * 服务端返回 deviceUid 与一次性 deviceSecret(deviceSecret 仅返回这一次,需立即安全落盘)。 + * - token:用 deviceUid + deviceSecret 换取 accessToken(用于 WebSocket Bearer 握手, + * 以及后续 TURN 凭证等受限接口)。accessToken 无 refreshToken,失效后重新走 token 换取。 + * + * 生产环境请将 PROVISION_SECRET 通过 BuildConfig / NDK 注入,切勿硬编码在源码明文。 + */ +public final class ApiClient { + + private static final String TAG = "ControlledApiClient"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + // 出厂预置共享密钥(部署注入)。此处为默认值,正式包应由 BuildConfig.DEVICE_PROVISION_SECRET 覆盖。 + private static final String PROVISION_SECRET = + BuildConfig.DEBUG ? "dev-device-provision-secret-change-me" : BuildConfig.DEVICE_PROVISION_SECRET; + + private final OkHttpClient http; + private final Gson gson = new Gson(); + + public ApiClient() { + this.http = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build(); + } + + /** 计算 provision 签名:HMAC-SHA256(secret, sn + "|" + nonce + "|" + timestamp) */ + public static String signProvision(String secret, String sn, String nonce, long timestamp) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String data = sn + "|" + nonce + "|" + timestamp; + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC 计算失败", e); + } + } + + private static String apiBase() { + return BuildConfig.API_BASE; // 例如 https://www.ttstd.com + } + + /** + * 第一步:provision,用 SN 证明出厂身份,获取 deviceUid 与一次性 deviceSecret。 + * + * @return 包含 deviceUid / deviceSecret 的 JsonObject;失败抛 RuntimeException。 + */ + public JsonObject provision(String sn, String model) { + long timestamp = System.currentTimeMillis() / 1000L; + String nonce = Long.toHexString(System.nanoTime()) + Long.toHexString(System.currentTimeMillis()); + String hmac = signProvision(PROVISION_SECRET, sn, nonce, timestamp); + + JsonObject body = new JsonObject(); + body.addProperty("sn", sn); + body.addProperty("model", model); + body.addProperty("nonce", nonce); + body.addProperty("timestamp", timestamp); + body.addProperty("hmac", hmac); + + Request request = new Request.Builder() + .url(apiBase() + "/api/device/provision") + .post(RequestBody.create(body.toString(), JSON)) + .build(); + + try (Response resp = http.newCall(request).execute()) { + return parse(resp, "provision"); + } catch (Exception e) { + Log.e(TAG, "provision 请求失败", e); + throw new RuntimeException("激活失败(provision): " + e.getMessage(), e); + } + } + + /** + * 第二步:token,用 deviceUid + deviceSecret 换取 accessToken。 + */ + public JsonObject token(String deviceUid, String deviceSecret) { + JsonObject body = new JsonObject(); + body.addProperty("deviceUid", deviceUid); + body.addProperty("deviceSecret", deviceSecret); + + Request request = new Request.Builder() + .url(apiBase() + "/api/device/token") + .post(RequestBody.create(body.toString(), JSON)) + .build(); + + try (Response resp = http.newCall(request).execute()) { + return parse(resp, "token"); + } catch (Exception e) { + Log.e(TAG, "token 请求失败", e); + throw new RuntimeException("令牌换取失败(token): " + e.getMessage(), e); + } + } + + /** 拉取 TURN 短期凭证(iceServers)。服务端未开启时返回 null。 */ + public JsonObject fetchTurnCredentials(String accessToken) { + Request request = new Request.Builder() + .url(apiBase() + "/api/client/turn-credentials") + .get() + .addHeader("Authorization", "Bearer " + accessToken) + .build(); + try (Response resp = http.newCall(request).execute()) { + if (!resp.isSuccessful()) return null; + ResponseBody b = resp.body(); + if (b == null) return null; + return gson.fromJson(b.string(), JsonObject.class); + } catch (Exception e) { + Log.w(TAG, "TURN 凭证拉取失败(忽略)", e); + return null; + } + } + + private JsonObject parse(Response resp, String step) throws Exception { + ResponseBody body = resp.body(); + String text = body != null ? body.string() : ""; + if (!resp.isSuccessful()) { + throw new RuntimeException(step + " 失败: HTTP " + resp.code() + " " + text); + } + return gson.fromJson(text, JsonObject.class); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/WebSocketClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/WebSocketClient.java index 21057e4..697281a 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/WebSocketClient.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/signaling/WebSocketClient.java @@ -1,16 +1,13 @@ package com.ttstd.controlled.signaling; -import android.os.Handler; -import android.os.Looper; import android.util.Log; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -18,209 +15,182 @@ import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; +/** + * 被控端信令 WebSocket 客户端。 + * + * 鉴权方式:通过 OkHttp 在握手请求头中携带 `Authorization: Bearer `。 + * 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。 + * + * 关闭码语义: + * - 4001 令牌失效:清空当前 accessToken,调用方应重新换取令牌后重连; + * - 4003 强制下线:停止重连,调用方应回到未激活/未连接状态。 + */ public class WebSocketClient { private static final String TAG = "WebSocketClient"; - // 应用层心跳:定期发送 PING,避免经过反向代理(nginx 等)空闲超时导致连接被断开。 - private static final long HEARTBEAT_INTERVAL_MS = 25_000; - // OkHttp 协议层 ping:保持 TCP 通道活跃,与心跳互补。 - private static final long PING_INTERVAL_MS = 20_000; - // 自动重连指数退避参数。 - private static final long RECONNECT_BASE_DELAY_MS = 2_000; - private static final long RECONNECT_MAX_DELAY_MS = 30_000; - private static final int RECONNECT_BACKOFF_STEPS = 5; - - private static final String PING_PAYLOAD = "{\"type\":\"PING\"}"; - + private final Gson gson = new Gson(); + private final SignalListener listener; private final String serverUrl; - private final String deviceId; - private final Gson gson = new GsonBuilder().disableHtmlEscaping().create(); - private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private OkHttpClient client; - private volatile WebSocket webSocket; - private SignalListener listener; + private final String token; + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - private ScheduledExecutorService scheduler; + private OkHttpClient client; + private WebSocket ws; + private boolean manualClose = false; private int reconnectAttempts = 0; - private final AtomicBoolean manuallyClosed = new AtomicBoolean(false); + private static final int MAX_RECONNECT_DELAY = 30_000; public interface SignalListener { + void onRegistered(String fromDeviceId); + void onMessage(SignalMessage message); void onConnected(); void onDisconnected(); - void onError(String error); - void onMessage(SignalMessage message); + void onError(String message); + /** 令牌失效(关闭码 4001),需重新换取令牌后重连。 */ + void onTokenExpired(); + /** 强制下线(关闭码 4003),需停止重连。 */ + void onForceLogout(); } - public WebSocketClient(String serverUrl, String deviceId) { + public WebSocketClient(String serverUrl, String token, SignalListener listener) { this.serverUrl = serverUrl; - this.deviceId = deviceId; - } - - public void setListener(SignalListener listener) { + this.token = token; this.listener = listener; } public void connect() { - manuallyClosed.set(false); - reconnectAttempts = 0; - ensureScheduler(); - doConnect(); - } - - private void ensureScheduler() { - if (scheduler == null || scheduler.isShutdown()) { - scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "WebSocketClient-Scheduler"); - t.setDaemon(true); - return t; - }); - } - } - - private void doConnect() { - if (manuallyClosed.get()) { + manualClose = false; + if (serverUrl == null || serverUrl.isEmpty()) { + listener.onError("服务器地址为空"); return; } - Log.i(TAG, "Connecting to " + serverUrl); + client = new OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.SECONDS) // 不依赖读超时,由心跳/ping 保活 - .writeTimeout(15, TimeUnit.SECONDS) - .pingInterval(PING_INTERVAL_MS, TimeUnit.MILLISECONDS) + .pingInterval(20, TimeUnit.SECONDS) .build(); - Request request = new Request.Builder().url(serverUrl).build(); - client.newWebSocket(request, new WebSocketListener() { + Request.Builder reqBuilder = new Request.Builder().url(serverUrl); + if (token != null && !token.isEmpty()) { + reqBuilder.addHeader("Authorization", "Bearer " + token); + } + Request request = reqBuilder.build(); + + ws = client.newWebSocket(request, new WebSocketListener() { @Override - public void onOpen(WebSocket ws, Response response) { - webSocket = ws; + public void onOpen(WebSocket webSocket, Response response) { + Log.d(TAG, "WebSocket 已连接"); reconnectAttempts = 0; - Log.i(TAG, "WebSocket connected"); - startHeartbeat(); - registerDevice(); - mainHandler.post(() -> { - if (listener != null) listener.onConnected(); - }); + // 不再发送 REGISTER,服务端根据 Bearer 令牌自动注册。 + listener.onConnected(); } @Override - public void onMessage(WebSocket ws, String text) { - Log.d(TAG, "Received: " + text); - try { - SignalMessage message = gson.fromJson(text, SignalMessage.class); - mainHandler.post(() -> { - if (listener != null) listener.onMessage(message); - }); - } catch (Exception e) { - Log.e(TAG, "Error parsing message", e); + public void onMessage(WebSocket webSocket, String text) { + SignalMessage msg = parse(text); + if (msg == null) return; + if ("REGISTER_SUCCESS".equals(msg.getType()) && msg.getFromDeviceId() != null) { + listener.onRegistered(msg.getFromDeviceId()); } + listener.onMessage(msg); } @Override - public void onClosed(WebSocket ws, int code, String reason) { - Log.i(TAG, "WebSocket closed: " + reason); - webSocket = null; - stopHeartbeat(); - mainHandler.post(() -> { - if (listener != null) listener.onDisconnected(); - }); - scheduleReconnect(); + public void onClosing(WebSocket webSocket, int code, String reason) { + Log.w(TAG, "WebSocket onClosing code=" + code + " reason=" + reason); + if (code == 4001) { + listener.onTokenExpired(); + webSocket.close(4001, reason); + return; + } + if (code == 4003) { + manualClose = true; + listener.onForceLogout(); + webSocket.close(4003, reason); + return; + } + webSocket.close(code, reason); } @Override - public void onFailure(WebSocket ws, Throwable t, Response response) { - Log.e(TAG, "WebSocket error: " + t.getMessage(), t); - webSocket = null; - stopHeartbeat(); - mainHandler.post(() -> { - if (listener != null) listener.onError(t.getMessage()); - }); - scheduleReconnect(); + public void onClosed(WebSocket webSocket, int code, String reason) { + Log.d(TAG, "WebSocket 已关闭 code=" + code); + if (manualClose) { + listener.onDisconnected(); + return; + } + handleReconnect(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + Log.e(TAG, "WebSocket 连接失败: " + t.getMessage(), t); + if (manualClose) { + listener.onDisconnected(); + return; + } + listener.onError("连接失败: " + t.getMessage()); + handleReconnect(); } }); } - private void startHeartbeat() { - if (scheduler == null || scheduler.isShutdown()) { - return; - } - scheduler.scheduleAtFixedRate(() -> { - WebSocket ws = webSocket; - if (ws != null && !manuallyClosed.get()) { - try { - ws.send(PING_PAYLOAD); - } catch (Exception e) { - Log.w(TAG, "Heartbeat send failed", e); - } - } - }, HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, TimeUnit.MILLISECONDS); - } - - private void stopHeartbeat() { - // 心跳任务随 scheduler 关闭或 webSocket 置空而停止,此处无需额外处理。 - } - - private void scheduleReconnect() { - if (manuallyClosed.get()) { - return; - } - if (scheduler == null || scheduler.isShutdown()) { - return; - } - long delay = Math.min( - RECONNECT_BASE_DELAY_MS * (1L << Math.min(reconnectAttempts, RECONNECT_BACKOFF_STEPS)), - RECONNECT_MAX_DELAY_MS); + private void handleReconnect() { reconnectAttempts++; - Log.i(TAG, "Scheduling reconnect in " + delay + "ms (attempt " + reconnectAttempts + ")"); + long delay = Math.min((long) Math.pow(2, Math.min(reconnectAttempts, 5)) * 1000, MAX_RECONNECT_DELAY); + Log.d(TAG, "第 " + reconnectAttempts + " 次重连,延迟 " + delay + "ms"); scheduler.schedule(() -> { - if (manuallyClosed.get()) { - return; - } - Log.i(TAG, "Attempting reconnect..."); - doConnect(); + if (!manualClose) connect(); }, delay, TimeUnit.MILLISECONDS); } - public void sendMessage(SignalMessage message) { - WebSocket ws = webSocket; - if (ws != null) { - String json = gson.toJson(message); - Log.d(TAG, "Sending: " + json); - ws.send(json); - } - } - public void disconnect() { - manuallyClosed.set(true); - WebSocket ws = webSocket; - webSocket = null; - stopHeartbeat(); - if (scheduler != null) { - scheduler.shutdownNow(); - scheduler = null; - } + manualClose = true; if (ws != null) { - try { - ws.close(1000, "Disconnecting"); - } catch (Exception ignored) { - } + ws.close(1000, "用户断开"); + ws = null; } if (client != null) { client.dispatcher().executorService().shutdown(); - client = null; + } + scheduler.shutdownNow(); + } + + private SignalMessage parse(String text) { + try { + JsonObject json = gson.fromJson(text, JsonObject.class); + SignalMessage msg = new SignalMessage(); + if (json.has("type")) msg.setType(json.get("type").getAsString()); + if (json.has("fromDeviceId")) msg.setFromDeviceId(json.get("fromDeviceId").getAsString()); + if (json.has("toDeviceId")) msg.setToDeviceId(json.get("toDeviceId").getAsString()); + if (json.has("deviceType")) msg.setDeviceType(json.get("deviceType").getAsString()); + if (json.has("payload")) msg.setPayload(json.get("payload").getAsString()); + if (json.has("authType")) msg.setAuthType(json.get("authType").getAsString()); + if (json.has("authValue")) msg.setAuthValue(json.get("authValue").getAsString()); + return msg; + } catch (Exception e) { + Log.e(TAG, "消息解析失败: " + e.getMessage(), e); + return null; } } - private void registerDevice() { - SignalMessage registerMsg = new SignalMessage(); - registerMsg.setType("REGISTER"); - registerMsg.setFromDeviceId(deviceId); - registerMsg.setDeviceType("CONTROLLED"); - sendMessage(registerMsg); + public void sendMessage(SignalMessage message) { + send(gson.toJson(message)); + } + + public void sendDeviceListRequest() { + JsonObject msg = new JsonObject(); + msg.addProperty("type", "DEVICE_LIST"); + send(msg.toString()); + } + + public void send(String message) { + if (ws != null) { + ws.send(message); + } } public boolean isConnected() { - return webSocket != null; + return ws != null && client != null && !client.dispatcher().executorService().isShutdown(); } } diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java new file mode 100644 index 0000000..dacc21b --- /dev/null +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceSecretStore.java @@ -0,0 +1,94 @@ +package com.ttstd.controlled.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +/** + * 被控端凭据安全存储。 + * + * 激活流程(provision / token)返回的 deviceSecret 是一次性凭据,且代表设备身份, + * 必须以加密方式落盘(EncryptedSharedPreferences)。deviceUid 与 accessToken 同样密文存储。 + * + * 注意:EncryptedSharedPreferences 的初始化可能抛出 GeneralSecurityException, + * 调用方需处理「无法创建加密存储」的退化场景(此时仅内存持有,不落盘)。 + */ +public final class DeviceSecretStore { + + private static final String FILE_NAME = "ttstd_device_secrets"; + private static final String KEY_DEVICE_UID = "device_uid"; + private static final String KEY_DEVICE_SECRET = "device_secret"; + private static final String KEY_ACCESS_TOKEN = "access_token"; + private static final String KEY_ACTIVATED = "activated"; + + private final SharedPreferences sp; + + public DeviceSecretStore(Context context) { + this.sp = create(context); + } + + private static SharedPreferences create(Context context) { + try { + MasterKey masterKey = new MasterKey.Builder(context) + .setKeyGenParameterSpec( + new KeyGenParameterSpec.Builder( + MasterKey.DEFAULT_MASTER_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build()) + .build(); + return EncryptedSharedPreferences.create( + context, + FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); + } catch (GeneralSecurityException | IOException e) { + // 退化:使用普通(非加密)SharedPreferences,仅作为兜底,避免崩溃。 + return context.getSharedPreferences(FILE_NAME + "_fallback", Context.MODE_PRIVATE); + } + } + + public void saveDevice(String deviceUid, String deviceSecret) { + sp.edit() + .putString(KEY_DEVICE_UID, deviceUid) + .putString(KEY_DEVICE_SECRET, deviceSecret) + .putBoolean(KEY_ACTIVATED, true) + .apply(); + } + + public void saveAccessToken(String token) { + sp.edit().putString(KEY_ACCESS_TOKEN, token).apply(); + } + + public String getDeviceUid() { + return sp.getString(KEY_DEVICE_UID, null); + } + + public String getDeviceSecret() { + return sp.getString(KEY_DEVICE_SECRET, null); + } + + public String getAccessToken() { + return sp.getString(KEY_ACCESS_TOKEN, null); + } + + public boolean isActivated() { + return sp.getBoolean(KEY_ACTIVATED, false) + && sp.getString(KEY_DEVICE_UID, null) != null + && sp.getString(KEY_DEVICE_SECRET, null) != null; + } + + public void clear() { + sp.edit().clear().apply(); + } +} diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceUtils.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceUtils.java index 8501755..5239b95 100644 --- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceUtils.java +++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/utils/DeviceUtils.java @@ -4,95 +4,45 @@ import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; import android.provider.Settings; -import android.text.TextUtils; import java.util.UUID; /** - * 设备信息工具类 + * 设备唯一标识工具。 + * + * 安全改造后,被控端以出厂 SN 作为激活身份(provision 用),不再由用户随意填写设备 ID。 + * 优先读取系统序列号/硬件序列号;非系统签名应用可能拿不到,则回退到稳定的 Android ID。 */ public class DeviceUtils { - /** - * 获取设备序列号(适用于系统签名应用) - *

- * 需要权限: android.permission.READ_PRIVILEGED_PHONE_STATE - * 注意:普通应用即使有 READ_PHONE_STATE 权限,在 Android 10+ 也无法获取序列号。 - * - * @return 设备序列号,获取失败可能返回 "unknown" - */ - @SuppressLint({"MissingPermission", "HardwareIds"}) - public static String getSystemSerialNumber() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - try { - // 对于系统应用,Build.getSerial() 应该能成功返回真实的硬件序列号 - return Build.getSerial(); - } catch (Exception e) { - return Build.SERIAL; - } - } - return Build.SERIAL; - } - - /** - * 获取设备标识(适用于普通应用) - *

- * 做了 Android 版本兼容。如果无法获取硬件序列号(如 Android 10+), - * 则尝试使用 Android ID。如果 Android ID 也获取不到,则通过硬件信息生成 UUID。 - * - * @param context 上下文 - * @return 设备唯一标识 - */ - @SuppressLint({"MissingPermission", "HardwareIds"}) - public static String getSerialNumber(Context context) { - String serial = null; + @SuppressLint("HardwareIds") + public static String getSerial() { + String serial = ""; try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // 尝试通过 getSerial 获取,Android 10+ 普通应用通常会抛异常或返回 unknown serial = Build.getSerial(); } else { serial = Build.SERIAL; } - } catch (Exception ignored) { + } catch (SecurityException e) { + // 无 READ_PHONE_STATE 或系统签名权限时拿不到,回退到未知。 + serial = ""; } - - // 校验序列号是否有效 - if (!TextUtils.isEmpty(serial) && !Build.UNKNOWN.equalsIgnoreCase(serial)) { - return serial; + if (serial == null || serial.isEmpty() || "unknown".equalsIgnoreCase(serial)) { + return ""; } - - // 尝试获取 Android ID - String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); - // 排除某些设备上已知的错误 Android ID ("9774d56d682e549c") - if (!TextUtils.isEmpty(androidId) && !"9774d56d682e549c".equals(androidId)) { - return androidId; - } - - // 如果上述方式都失败,则根据硬件机型信息生成 UUID - return getDeviceUuid(); + return serial; } - /** - * 使用硬件机型信息生成 UUID - * 这种方案在设备重启或刷机后通常能保持稳定,但在系统升级导致某些 Build 字段变化时可能会变。 - */ - private static String getDeviceUuid() { - String devInfo = "35" + // 模拟 IMEI 的前缀 - Build.BOARD.length() % 10 + - Build.BRAND.length() % 10 + - Build.SUPPORTED_ABIS[0].length() % 10 + - Build.DEVICE.length() % 10 + - Build.DISPLAY.length() % 10 + - Build.HOST.length() % 10 + - Build.ID.length() % 10 + - Build.MANUFACTURER.length() % 10 + - Build.MODEL.length() % 10 + - Build.PRODUCT.length() % 10 + - Build.TAGS.length() % 10 + - Build.TYPE.length() % 10 + - Build.USER.length() % 10; + /** 稳定的设备标识(优先 SN,否则 Android ID)。用于激活 SN 字段与日志。 */ + @SuppressLint("HardwareIds") + public static String getStableId(Context context) { + String sn = getSerial(); + if (!sn.isEmpty()) return sn; + return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + } - // 使用 Build.SERIAL 参与 hash,增加独特性(即使它是 "unknown" 也是一种标识) - return new UUID(devInfo.hashCode(), Build.SERIAL.hashCode()).toString(); + public static String generateRandomDeviceId() { + return "web-" + UUID.randomUUID().toString().substring(0, 8); } } diff --git a/WebRTCControlled/app/src/main/res/layout/activity_main.xml b/WebRTCControlled/app/src/main/res/layout/activity_main.xml index 34eed32..1743dcd 100644 --- a/WebRTCControlled/app/src/main/res/layout/activity_main.xml +++ b/WebRTCControlled/app/src/main/res/layout/activity_main.xml @@ -40,7 +40,7 @@ + android:enabled="false" + android:text="" /> showAuthDialog()); btnDisconnect.setOnClickListener(v -> disconnect()); @@ -312,9 +330,8 @@ public class MainActivity extends AppCompatActivity { private void showAuthDialog() { String serverUrl = etServerUrl.getText().toString().trim(); String target = etTargetDeviceId.getText().toString().trim(); - String myId = etDeviceId.getText().toString().trim(); - if (serverUrl.isEmpty() || target.isEmpty() || myId.isEmpty()) { - Toast.makeText(this, "请先填写服务器地址、设备ID和目标设备ID", Toast.LENGTH_SHORT).show(); + if (serverUrl.isEmpty() || target.isEmpty()) { + Toast.makeText(this, "请先填写服务器地址和目标设备ID", Toast.LENGTH_SHORT).show(); return; } @@ -369,36 +386,141 @@ public class MainActivity extends AppCompatActivity { pendingAuthValue = authValue; } dialog.dismiss(); - connectToControlled(); + // 先确保已登录(Bearer token),再发起信令连接。 + ensureAuthenticated(this::connectToControlled); })); dialog.show(); } + /** + * 确保已登录:若本地已有 accessToken 则直接回调;否则弹出登录对话框, + * 登录成功后回调。登录失败则提示并中止。 + */ + private void ensureAuthenticated(Runnable onAuthenticated) { + if (tokenStore.hasTokens()) { + onAuthenticated.run(); + return; + } + showLoginDialog(success -> { + if (success) onAuthenticated.run(); + else Toast.makeText(this, "请先登录账号", Toast.LENGTH_SHORT).show(); + }); + } + + /** 登录对话框:输入用户名/密码,调用 /api/auth/login,保存令牌。 */ + private void showLoginDialog(LoginCallback callback) { + View view = getLayoutInflater().inflate(R.layout.dialog_login, null); + EditText etUser = view.findViewById(R.id.et_username); + EditText etPass = view.findViewById(R.id.et_password); + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle("登录") + .setView(view) + .setNegativeButton("取消", (d, w) -> callback.onResult(false)) + .setPositiveButton("登录", null) + .create(); + dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE) + .setOnClickListener(v -> { + String user = etUser.getText().toString().trim(); + String pass = etPass.getText().toString().trim(); + if (user.isEmpty() || pass.isEmpty()) { + Toast.makeText(this, "请输入用户名和密码", Toast.LENGTH_SHORT).show(); + return; + } + new Thread(() -> { + try { + JsonObject resp = apiClient.login(user, pass); + String at = resp.has("accessToken") ? resp.get("accessToken").getAsString() : null; + String rt = resp.has("refreshToken") ? resp.get("refreshToken").getAsString() : null; + if (at == null || rt == null) throw new IllegalStateException("登录返回缺失"); + tokenStore.save(at, rt, user); + runOnUiThread(() -> { + dialog.dismiss(); + callback.onResult(true); + }); + } catch (Exception e) { + runOnUiThread(() -> Toast.makeText(this, "登录失败: " + e.getMessage(), Toast.LENGTH_LONG).show()); + } + }).start(); + })); + dialog.show(); + } + + /** 登录结果回调。 */ + private interface LoginCallback { + void onResult(boolean success); + } + private void connectToControlled() { String serverUrl = etServerUrl.getText().toString().trim(); targetDeviceId = etTargetDeviceId.getText().toString().trim(); - myDeviceId = etDeviceId.getText().toString().trim(); - if (serverUrl.isEmpty() || targetDeviceId.isEmpty() || myDeviceId.isEmpty()) { - Toast.makeText(this, "请填写所有字段", Toast.LENGTH_SHORT).show(); + if (serverUrl.isEmpty() || targetDeviceId.isEmpty()) { + Toast.makeText(this, "请填写服务器地址和目标设备ID", Toast.LENGTH_SHORT).show(); return; } tvStatus.setText("状态: 正在连接信令服务器..."); - // 初始化 WebSocket - wsClient = new WebSocketClient(serverUrl, myDeviceId); + // 确保 accessToken 有效(必要时刷新),随后携带 Bearer token 建立 WebSocket。 + new Thread(() -> { + try { + String token = ensureAccessToken(); + runOnUiThread(() -> buildWebSocketAndConnect(serverUrl, token)); + } catch (Exception e) { + runOnUiThread(() -> { + tvStatus.setText("状态: 认证失败 - " + e.getMessage()); + Toast.makeText(this, "登录已失效,请重新登录", Toast.LENGTH_LONG).show(); + }); + } + }).start(); + } + + /** 返回有效的 accessToken:若本地有则校验,失效则用 refreshToken 刷新。 */ + private String ensureAccessToken() { + String at = tokenStore.getAccessToken(); + if (at != null) { + try { + apiClient.verify(at); + return at; + } catch (ApiClient.ApiException e) { + if (e.httpCode != 401) return at; // 非鉴权错误,暂用原 token + } + } + // 刷新 + String rt = tokenStore.getRefreshToken(); + if (rt == null) throw new IllegalStateException("无 refreshToken"); + JsonObject resp = apiClient.refresh(rt); + String newAt = resp.has("accessToken") ? resp.get("accessToken").getAsString() : null; + if (newAt == null) throw new IllegalStateException("刷新失败"); + tokenStore.saveAccessToken(newAt); + if (resp.has("refreshToken")) tokenStore.saveRefreshToken(resp.get("refreshToken").getAsString()); + return newAt; + } + + private void buildWebSocketAndConnect(String serverUrl, String token) { + wsClient = new WebSocketClient(serverUrl, token); wsClient.setListener(new WebSocketClient.SignalListener() { @Override - public void onConnected() { - tvStatus.setText("状态: 已连接信令服务器,正在发起连接..."); - // 连接成功后初始化 WebRTC 并创建 Offer + public void onRegistered(String fromDeviceId) { + // 服务端下发本机 deviceId(CONTROLLER),用于 WebRTC Offer 标识。 + myDeviceId = fromDeviceId; + etDeviceId.setText(fromDeviceId); + tvStatus.setText("状态: 已注册 (" + fromDeviceId + "),正在发起连接..."); + // 注册成功后初始化 WebRTC 并创建 Offer(myDeviceId 此时已就绪)。 initWebRtcAndConnect(); + // 拉取可连接的被控端绑定列表(仅已绑定设备) + loadBindings(); + } + + @Override + public void onConnected() { + tvStatus.setText("状态: 已连接信令服务器,等待注册..."); } @Override public void onDisconnected() { tvStatus.setText("状态: 已断开连接"); + stopHeartbeat(); updateUI(false); } @@ -413,8 +535,99 @@ public class MainActivity extends AppCompatActivity { public void onMessage(SignalMessage message) { handleSignalMessage(message); } + + @Override + public void onTokenExpired() { + // 关闭码 4001:单飞刷新令牌后重连。 + if (refreshInFlight.compareAndSet(false, true)) { + new Thread(() -> { + try { + String rt = tokenStore.getRefreshToken(); + if (rt == null) throw new IllegalStateException("无 refreshToken"); + JsonObject resp = apiClient.refresh(rt); + String newAt = resp.has("accessToken") ? resp.get("accessToken").getAsString() : null; + if (newAt == null) throw new IllegalStateException("刷新失败"); + tokenStore.saveAccessToken(newAt); + if (resp.has("refreshToken")) tokenStore.saveRefreshToken(resp.get("refreshToken").getAsString()); + String srv = etServerUrl.getText().toString().trim(); + runOnUiThread(() -> buildWebSocketAndConnect(srv, newAt)); + } catch (Exception e) { + runOnUiThread(() -> { + Toast.makeText(MainActivity.this, "令牌刷新失败,请重新登录", Toast.LENGTH_LONG).show(); + logoutAndReset(); + }); + } finally { + refreshInFlight.set(false); + } + }).start(); + } + } + + @Override + public void onForceLogout() { + // 关闭码 4003:强制下线,停止重连并跳回登录。 + runOnUiThread(() -> { + Toast.makeText(MainActivity.this, "账号已在其他位置登录,已强制下线", Toast.LENGTH_LONG).show(); + logoutAndReset(); + }); + } }); wsClient.connect(); + startHeartbeat(); + } + + /** 退出登录并重置 UI 状态(清空令牌、停止连接)。 */ + private void logoutAndReset() { + if (wsClient != null) wsClient.disconnect(); + wsClient = null; + if (webRtcClient != null) { webRtcClient.close(); webRtcClient = null; } + stopHeartbeat(); + tokenStore.clear(); + myDeviceId = null; + etDeviceId.setText(""); + tvStatus.setText("状态: 已退出登录"); + Toast.makeText(this, "请重新登录后再连接", Toast.LENGTH_SHORT).show(); + } + + private void startHeartbeat() { + stopHeartbeat(); + heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(); + heartbeatScheduler.scheduleAtFixedRate(() -> { + if (wsClient != null) wsClient.sendHeartbeat(); + }, 25, 25, TimeUnit.SECONDS); + } + + private void stopHeartbeat() { + if (heartbeatScheduler != null) { + heartbeatScheduler.shutdownNow(); + heartbeatScheduler = null; + } + } + + /** 拉取本机可连接的被控端(仅已绑定设备),用于辅助用户选择目标。 */ + private void loadBindings() { + new Thread(() -> { + try { + JsonObject resp = apiClient.bindings(tokenStore.getAccessToken()); + if (resp == null || !resp.has("bindings")) return; + List list = new ArrayList<>(); + for (JsonElement el : resp.getAsJsonArray("bindings")) { + if (el.isJsonObject()) { + JsonObject o = el.getAsJsonObject(); + String uid = o.has("deviceUid") ? o.get("deviceUid").getAsString() + : (o.has("deviceId") ? o.get("deviceId").getAsString() : ""); + list.add(uid); + } else { + list.add(el.getAsString()); + } + } + if (!list.isEmpty()) { + runOnUiThread(() -> Toast.makeText(this, + "已绑定设备: " + String.join(", ", list), Toast.LENGTH_LONG).show()); + } + } catch (Exception ignored) { + } + }).start(); } private void initWebRtcAndConnect() { @@ -637,6 +850,7 @@ public class MainActivity extends AppCompatActivity { } private void disconnect() { + stopHeartbeat(); if (webRtcClient != null) { webRtcClient.close(); webRtcClient = null; diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/ApiClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/ApiClient.java new file mode 100644 index 0000000..da6aba2 --- /dev/null +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/ApiClient.java @@ -0,0 +1,117 @@ +package com.ttstd.controller.signaling; + +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.ttstd.controller.BuildConfig; + +import java.util.concurrent.TimeUnit; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。 + * + * - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀); + * - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken); + * - verify() → 校验 accessToken 是否仍有效; + * - bindings() → 本机可连接的被控端列表(仅已绑定设备); + * - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。 + * + * 刷新单飞锁由调用方(MainActivity)保证并发只触发一次。 + */ +public final class ApiClient { + + private static final String TAG = "ControllerApiClient"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private final OkHttpClient http; + private final Gson gson = new Gson(); + + public ApiClient() { + this.http = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build(); + } + + public static String apiBase() { + return BuildConfig.API_BASE; + } + + public JsonObject login(String username, String password) { + JsonObject body = new JsonObject(); + body.addProperty("username", username); + body.addProperty("password", password); + return post("/api/auth/login", body, false, null); + } + + public JsonObject register(String username, String password) { + JsonObject body = new JsonObject(); + body.addProperty("username", username); + body.addProperty("password", password); + return post("/api/auth/register", body, false, null); + } + + /** 刷新令牌。需带 refreshToken(作为 body)。 */ + public JsonObject refresh(String refreshToken) { + JsonObject body = new JsonObject(); + body.addProperty("refreshToken", refreshToken); + return post("/api/auth/refresh", body, false, null); + } + + public JsonObject verify(String accessToken) { + return get("/api/client/verify", accessToken); + } + + public JsonObject bindings(String accessToken) { + return get("/api/client/bindings", accessToken); + } + + public JsonObject turnCredentials(String accessToken) { + return get("/api/client/turn-credentials", accessToken); + } + + private JsonObject post(String path, JsonObject body, boolean auth, String token) { + Request.Builder b = new Request.Builder().url(apiBase() + path) + .post(RequestBody.create(body.toString(), JSON)); + if (auth && token != null) b.addHeader("Authorization", "Bearer " + token); + return call(b.build(), path); + } + + private JsonObject get(String path, String token) { + Request.Builder b = new Request.Builder().url(apiBase() + path).get(); + if (token != null) b.addHeader("Authorization", "Bearer " + token); + return call(b.build(), path); + } + + private JsonObject call(Request request, String step) { + try (Response resp = http.newCall(request).execute()) { + ResponseBody body = resp.body(); + String text = body != null ? body.string() : ""; + if (!resp.isSuccessful()) { + throw new ApiException("HTTP " + resp.code() + " " + text, resp.code()); + } + return gson.fromJson(text, JsonObject.class); + } catch (Exception e) { + Log.e(TAG, step + " 请求失败", e); + if (e instanceof ApiException) throw (ApiException) e; + throw new ApiException(e.getMessage(), -1); + } + } + + /** API 调用异常,携带 HTTP 状态码(401 表示令牌失效)。 */ + public static class ApiException extends RuntimeException { + public final int httpCode; + public ApiException(String msg, int code) { + super(msg); + this.httpCode = code; + } + } +} diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/WebSocketClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/WebSocketClient.java index ad82169..a2bdbcc 100644 --- a/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/WebSocketClient.java +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/WebSocketClient.java @@ -5,6 +5,11 @@ import android.os.Looper; import android.util.Log; import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -12,28 +17,48 @@ import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; +/** + * 主控端信令 WebSocket 客户端。 + * + * 鉴权方式:通过 OkHttp 在握手请求头中携带 `Authorization: Bearer `。 + * 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS(含 fromDeviceId)。 + * + * 关闭码语义: + * - 4001 令牌失效:调用方应刷新 accessToken 后重连; + * - 4003 强制下线:停止重连,回到登录界面。 + */ public class WebSocketClient { private static final String TAG = "WebSocketClient"; private final String serverUrl; - private final String deviceId; + private final String token; private final Gson gson = new Gson(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private OkHttpClient client; private WebSocket webSocket; private SignalListener listener; + private int reconnectAttempts = 0; + private boolean manualClose = false; + private static final int MAX_RECONNECT_DELAY = 30_000; public interface SignalListener { + void onRegistered(String fromDeviceId); void onConnected(); void onDisconnected(); void onError(String error); void onMessage(SignalMessage message); + /** 令牌失效(关闭码 4001),需刷新令牌后重连。 */ + void onTokenExpired(); + /** 强制下线(关闭码 4003),需停止重连并跳登录。 */ + void onForceLogout(); } - public WebSocketClient(String serverUrl, String deviceId) { + public WebSocketClient(String serverUrl, String token) { this.serverUrl = serverUrl; - this.deviceId = deviceId; + this.token = token; } public void setListener(SignalListener listener) { @@ -41,14 +66,27 @@ public class WebSocketClient { } public void connect() { - client = new OkHttpClient.Builder().build(); - Request request = new Request.Builder().url(serverUrl).build(); + manualClose = false; + if (serverUrl == null || serverUrl.isEmpty()) { + if (listener != null) listener.onError("服务器地址为空"); + return; + } + client = new OkHttpClient.Builder() + .pingInterval(20, TimeUnit.SECONDS) + .build(); + + Request.Builder reqBuilder = new Request.Builder().url(serverUrl); + if (token != null && !token.isEmpty()) { + reqBuilder.addHeader("Authorization", "Bearer " + token); + } + Request request = reqBuilder.build(); webSocket = client.newWebSocket(request, new WebSocketListener() { @Override public void onOpen(WebSocket ws, Response response) { Log.i(TAG, "WebSocket connected"); - registerDevice(); + reconnectAttempts = 0; + // 不再发送 REGISTER,服务端根据 Bearer 令牌自动注册。 mainHandler.post(() -> { if (listener != null) listener.onConnected(); }); @@ -59,6 +97,13 @@ public class WebSocketClient { Log.d(TAG, "Received: " + text); try { SignalMessage message = gson.fromJson(text, SignalMessage.class); + if (message != null && "REGISTER_SUCCESS".equals(message.getType()) + && message.getFromDeviceId() != null) { + final String fromDeviceId = message.getFromDeviceId(); + mainHandler.post(() -> { + if (listener != null) listener.onRegistered(fromDeviceId); + }); + } mainHandler.post(() -> { if (listener != null) listener.onMessage(message); }); @@ -67,24 +112,66 @@ public class WebSocketClient { } } + @Override + public void onClosing(WebSocket ws, int code, String reason) { + if (code == 4001) { + Log.w(TAG, "WebSocket closing 4001 (token expired)"); + mainHandler.post(() -> { + if (listener != null) listener.onTokenExpired(); + }); + ws.close(4001, reason); + return; + } + if (code == 4003) { + Log.w(TAG, "WebSocket closing 4003 (force logout)"); + manualClose = true; + mainHandler.post(() -> { + if (listener != null) listener.onForceLogout(); + }); + ws.close(4003, reason); + return; + } + ws.close(code, reason); + } + @Override public void onClosed(WebSocket ws, int code, String reason) { Log.i(TAG, "WebSocket closed: " + reason); - mainHandler.post(() -> { - if (listener != null) listener.onDisconnected(); - }); + if (manualClose) { + mainHandler.post(() -> { + if (listener != null) listener.onDisconnected(); + }); + return; + } + handleReconnect(); } @Override public void onFailure(WebSocket ws, Throwable t, Response response) { Log.e(TAG, "WebSocket error: " + t.getMessage(), t); + if (manualClose) { + mainHandler.post(() -> { + if (listener != null) listener.onError(t.getMessage()); + }); + return; + } mainHandler.post(() -> { if (listener != null) listener.onError(t.getMessage()); }); + handleReconnect(); } }); } + private void handleReconnect() { + reconnectAttempts++; + long delay = Math.min((long) Math.pow(2, Math.min(reconnectAttempts, 5)) * 1000, MAX_RECONNECT_DELAY); + Log.d(TAG, "第 " + reconnectAttempts + " 次重连,延迟 " + delay + "ms"); + scheduler.schedule(() -> { + if (!manualClose) connect(); + }, delay, TimeUnit.MILLISECONDS); + } + public void sendMessage(SignalMessage message) { if (webSocket != null) { String json = gson.toJson(message); @@ -93,24 +180,29 @@ public class WebSocketClient { } } + public void sendHeartbeat() { + if (webSocket != null && client != null && !client.dispatcher().executorService().isShutdown()) { + try { + JsonObject ping = new JsonObject(); + ping.addProperty("type", "PING"); + webSocket.send(ping.toString()); + } catch (Exception ignored) { + } + } + } + public void disconnect() { + manualClose = true; if (webSocket != null) { webSocket.close(1000, "Disconnecting"); } if (client != null) { client.dispatcher().executorService().shutdown(); } - } - - private void registerDevice() { - SignalMessage registerMsg = new SignalMessage(); - registerMsg.setType("REGISTER"); - registerMsg.setFromDeviceId(deviceId); - registerMsg.setDeviceType("CONTROLLER"); - sendMessage(registerMsg); + scheduler.shutdownNow(); } public boolean isConnected() { - return webSocket != null; + return webSocket != null && client != null && !client.dispatcher().executorService().isShutdown(); } } diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java b/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java new file mode 100644 index 0000000..422fae6 --- /dev/null +++ b/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java @@ -0,0 +1,91 @@ +package com.ttstd.controller.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +/** + * 主控端令牌安全存储。 + * + * 登录得到的 accessToken 与 refreshToken 以密文落盘(EncryptedSharedPreferences)。 + * refreshToken 为一次性(ses_ 前缀),刷新后服务端可能轮换,需覆盖保存。 + */ +public final class TokenStore { + + private static final String FILE_NAME = "ttstd_controller_tokens"; + private static final String KEY_ACCESS = "access_token"; + private static final String KEY_REFRESH = "refresh_token"; + private static final String KEY_USERNAME = "username"; + + private final SharedPreferences sp; + + public TokenStore(Context context) { + this.sp = create(context); + } + + private static SharedPreferences create(Context context) { + try { + MasterKey masterKey = new MasterKey.Builder(context) + .setKeyGenParameterSpec( + new KeyGenParameterSpec.Builder( + MasterKey.DEFAULT_MASTER_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build()) + .build(); + return EncryptedSharedPreferences.create( + context, + FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); + } catch (GeneralSecurityException | IOException e) { + return context.getSharedPreferences(FILE_NAME + "_fallback", Context.MODE_PRIVATE); + } + } + + public void save(String accessToken, String refreshToken, String username) { + SharedPreferences.Editor editor = sp.edit(); + editor.putString(KEY_ACCESS, accessToken); + if (refreshToken != null) editor.putString(KEY_REFRESH, refreshToken); + if (username != null) editor.putString(KEY_USERNAME, username); + editor.apply(); + } + + public void saveAccessToken(String accessToken) { + sp.edit().putString(KEY_ACCESS, accessToken).apply(); + } + + public void saveRefreshToken(String refreshToken) { + if (refreshToken != null) sp.edit().putString(KEY_REFRESH, refreshToken).apply(); + } + + public String getAccessToken() { + return sp.getString(KEY_ACCESS, null); + } + + public String getRefreshToken() { + return sp.getString(KEY_REFRESH, null); + } + + public String getUsername() { + return sp.getString(KEY_USERNAME, null); + } + + public boolean hasTokens() { + return sp.getString(KEY_ACCESS, null) != null && sp.getString(KEY_REFRESH, null) != null; + } + + public void clear() { + sp.edit().clear().apply(); + } +} diff --git a/WebRTCController/app/src/main/res/layout/dialog_login.xml b/WebRTCController/app/src/main/res/layout/dialog_login.xml new file mode 100644 index 0000000..f44a6b7 --- /dev/null +++ b/WebRTCController/app/src/main/res/layout/dialog_login.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/WebRTCController/app/src/main/res/values/strings.xml b/WebRTCController/app/src/main/res/values/strings.xml index a2888d1..68c30f7 100644 --- a/WebRTCController/app/src/main/res/values/strings.xml +++ b/WebRTCController/app/src/main/res/values/strings.xml @@ -12,4 +12,8 @@ 请输入动态验证码 请输入固定密码 请输入验证码或密码 + + + 用户名 + 密码 diff --git a/WebRTCControllerWeb/src/App.vue b/WebRTCControllerWeb/src/App.vue index 82f9070..3b3df20 100644 --- a/WebRTCControllerWeb/src/App.vue +++ b/WebRTCControllerWeb/src/App.vue @@ -1,42 +1,54 @@ diff --git a/WebRTCControllerWeb/src/components/ConnectionPanel.vue b/WebRTCControllerWeb/src/components/ConnectionPanel.vue index 0965c49..9bdc5af 100644 --- a/WebRTCControllerWeb/src/components/ConnectionPanel.vue +++ b/WebRTCControllerWeb/src/components/ConnectionPanel.vue @@ -27,8 +27,8 @@ function onConnectDevice() {

- - + +
@@ -42,7 +42,7 @@ function onConnectDevice() {
- 暂无在线被控端。请确认 Android 被控端 (WebRTCControlled) 已启动并连接到同一信令服务器。 + 尚无已绑定设备。请在 Android 被控端内通过配对码完成绑定,绑定列表由服务端下发(仅显示你已绑定的设备)。
+import { ref } from 'vue'; +import { store, login, register, connectSignaling } from '../store/controllerStore'; + +const username = ref(''); +const password = ref(''); +const showRegister = ref(false); +const regUsername = ref(''); +const regPassword = ref(''); +const regBusy = ref(false); +const regError = ref(''); + +async function onLogin() { + const ok = await login(username.value.trim(), password.value); + if (ok) { + await connectSignaling(); + } +} + +async function onRegister() { + regError.value = ''; + regBusy.value = true; + try { + await register({ username: regUsername.value.trim(), password: regPassword.value }); + // 注册成功后直接登录并连接。 + username.value = regUsername.value.trim(); + password.value = regPassword.value; + showRegister.value = false; + await onLogin(); + } catch (e) { + regError.value = e.message || '注册失败'; + } finally { + regBusy.value = false; + } +} + + + + + diff --git a/WebRTCControllerWeb/src/services/ApiClient.js b/WebRTCControllerWeb/src/services/ApiClient.js new file mode 100644 index 0000000..33679e9 --- /dev/null +++ b/WebRTCControllerWeb/src/services/ApiClient.js @@ -0,0 +1,119 @@ +// HTTP API 客户端:对接安全信令服务器的账号体系与自助接口。 +// 令牌存储策略(参考服务端实现约束): +// - accessToken:内存中保存(掉线即失,需重新登录); +// - refreshToken:sessionStorage(一次性、ses_ 前缀,页面关闭即清除,降低泄露面)。 +const API_BASE = (import.meta.env.VITE_API_BASE || 'https://www.ttstd.com').replace(/\/$/, ''); + +const ACCESS_KEY = 'ttstd.accessToken'; +const REFRESH_KEY = 'ttstd.refreshToken'; + +let accessToken = sessionStorage.getItem(ACCESS_KEY) || ''; +let refreshToken = sessionStorage.getItem(REFRESH_KEY) || ''; +// 刷新单飞:避免并发请求同时触发多次刷新。 +let refreshing = null; + +export function getAccessToken() { + return accessToken; +} + +export function isLoggedIn() { + return !!accessToken; +} + +export function setTokens({ accessToken: at, refreshToken: rt }) { + accessToken = at || ''; + if (rt) refreshToken = rt; + if (accessToken) sessionStorage.setItem(ACCESS_KEY, accessToken); + else sessionStorage.removeItem(ACCESS_KEY); + if (refreshToken) sessionStorage.setItem(REFRESH_KEY, refreshToken); + else sessionStorage.removeItem(REFRESH_KEY); +} + +export function clearTokens() { + accessToken = ''; + refreshToken = ''; + sessionStorage.removeItem(ACCESS_KEY); + sessionStorage.removeItem(REFRESH_KEY); +} + +async function request(path, { method = 'POST', body, auth = false } = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (auth && accessToken) headers['Authorization'] = 'Bearer ' + accessToken; + const res = await fetch(API_BASE + path, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 401) { + // 可能是 accessToken 失效,由调用方决定是否刷新。 + const data = await res.json().catch(() => ({})); + const err = new Error(data.error || 'UNAUTHORIZED'); + err.code = data.code || 'UNAUTHORIZED'; + err.status = 401; + throw err; + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const err = new Error(data.error || ('HTTP ' + res.status)); + err.code = data.code; + err.status = res.status; + throw err; + } + return res.json(); +} + +export async function login(username, password) { + const data = await request('/api/auth/login', { body: { username, password } }); + setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken }); + return data; +} + +export async function register(body) { + return request('/api/auth/register', { body }); +} + +export async function logout() { + if (refreshToken) { + try { await request('/api/auth/logout', { method: 'POST', body: { refreshToken } }); } catch { /* ignore */ } + } + clearTokens(); +} + +// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。 +export async function refresh() { + if (!refreshToken) { + clearTokens(); + throw new Error('NO_REFRESH_TOKEN'); + } + if (refreshing) return refreshing; + refreshing = (async () => { + try { + const data = await request('/api/auth/refresh', { body: { refreshToken } }); + // 服务端可能轮换 refreshToken(一次性),若返回新的则覆盖。 + setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken }); + return data; + } finally { + refreshing = null; + } + })(); + return refreshing; +} + +// 令牌校验(可选,用于启动恢复时确认 accessToken 是否有效)。 +export async function verify() { + return request('/api/client/verify', { method: 'GET', auth: true }); +} + +// 取本机可连接的被控端列表(仅返回已绑定的设备)。 +export async function listBindings() { + return request('/api/client/bindings', { method: 'GET', auth: true }); +} + +// 拉取 TURN 短期凭证(服务端开启时返回 iceServers)。 +export async function fetchTurnCredentials() { + try { + return await request('/api/client/turn-credentials', { method: 'GET', auth: true }); + } catch { + return null; + } +} diff --git a/WebRTCControllerWeb/src/services/SignalingClient.js b/WebRTCControllerWeb/src/services/SignalingClient.js index db5a1ff..eb785c4 100644 --- a/WebRTCControllerWeb/src/services/SignalingClient.js +++ b/WebRTCControllerWeb/src/services/SignalingClient.js @@ -1,25 +1,38 @@ // 信令客户端:对应 Android 端 WebSocketClient / Flutter signaling_client.dart。 -// 连接成功后自动发送 REGISTER(deviceType=CONTROLLER), -// 负责 OFFER / ICE_CANDIDATE 的发送,以及 ANSWER / ICE_CANDIDATE / 通知类的接收与转发。 +// +// 鉴权方式:浏览器原生 WebSocket 无法在握手阶段自定义请求头, +// 因此将 accessToken 通过子协议(Sec-WebSocket-Protocol)传递: +// new WebSocket(url, ['signal.v1', 'auth.' + token]) +// 服务端从首子协议取出 auth. 进行 Bearer 校验。 +// +// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。 +import { getAccessToken, refresh } from './ApiClient'; + export class SignalingClient { - constructor({ serverUrl, deviceId, onConnected, onDisconnected, onError, onMessage }) { + constructor({ serverUrl, onConnected, onDisconnected, onError, onMessage, onTokenExpired, onForceLogout }) { this.serverUrl = serverUrl; - this.deviceId = deviceId; this.onConnected = onConnected; this.onDisconnected = onDisconnected; this.onError = onError; this.onMessage = onMessage; + // 令牌过期(4001):调用方应刷新令牌后重连。 + this.onTokenExpired = onTokenExpired; + // 强制下线(4003):调用方应停止重连并跳回登录。 + this.onForceLogout = onForceLogout; this.ws = null; + this.deviceId = ''; } connect() { try { - this.ws = new WebSocket(this.serverUrl); + const token = getAccessToken(); + // 子协议携带版本标识与 Bearer token。 + const protocols = ['signal.v1']; + if (token) protocols.push('auth.' + token); + this.ws = new WebSocket(this.serverUrl, protocols); this.ws.onopen = () => { - this.register(); - this.onConnected && this.onConnected(); - // 启动心跳保活:每 25 秒发送 ping,防止中间代理或服务器因空闲超时断开连接 this._startHeartbeat(); + this.onConnected && this.onConnected(); }; this.ws.onmessage = (ev) => { let msg; @@ -28,22 +41,26 @@ export class SignalingClient { } catch { return; } + // 记录服务端下发的本机 fromDeviceId(REGISTER_SUCCESS)。 + if (msg.type === 'REGISTER_SUCCESS' && msg.fromDeviceId) { + this.deviceId = msg.fromDeviceId; + } this.onMessage && this.onMessage(msg); }; this.ws.onclose = (ev) => { - // 记录关闭码和原因,便于诊断断连根因: - // 1000 = 正常关闭, 1001 = 离开, 1006 = 异常断开(无 close frame) - // 1009 = 消息过大, 1011 = 服务端异常 - let hint = ''; - if (ev.code === 1009) { - // 关闭帧由服务端发出:服务端 WebSocket 单条消息缓冲上限过小, - // 导致 SDP/ICE 等稍大的消息被拒绝。需在信令服务端调大消息大小上限 - // (Spring 原生 WebSocket 用 configureWebSocketTransport#setMessageSizeLimit)。 - hint = '(消息过大:请检查信令服务端 WebSocket 的 messageSizeLimit 配置是否过小)'; - } - console.warn('[信令] WebSocket 已关闭 code=%d reason=%s wasClean=%s%s', - ev.code, ev.reason || '(无)', ev.wasClean, hint); this._stopHeartbeat(); + console.warn('[信令] WebSocket 已关闭 code=%d reason=%s wasClean=%s', + ev.code, ev.reason || '(无)', ev.wasClean); + if (ev.code === 4001) { + // 令牌失效:先尝试刷新,刷新成功由调用方重连;失败则跳登录。 + (this.onTokenExpired || this.onDisconnected)?.(ev.reason); + return; + } + if (ev.code === 4003) { + // 强制下线:停止重连,跳回登录。 + (this.onForceLogout || this.onDisconnected)?.(ev.reason); + return; + } this.onDisconnected && this.onDisconnected(ev.code, ev.reason); }; this.ws.onerror = (e) => { @@ -57,9 +74,9 @@ export class SignalingClient { _startHeartbeat() { this._stopHeartbeat(); + // 每 25 秒发送 PING,保持连接活跃(服务端忽略未知类型)。 this._heartbeatTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { - // 发送轻量级 ping 消息,保持连接活跃(服务端会忽略未知类型消息) try { this.ws.send(JSON.stringify({ type: 'PING' })); } catch { /* ignore */ } } }, 25000); @@ -69,22 +86,14 @@ export class SignalingClient { if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; } } - register() { - this.send({ - type: 'REGISTER', - fromDeviceId: this.deviceId, - deviceType: 'CONTROLLER', - }); - } - requestDeviceList() { - this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId }); + // 服务端不再接受 DEVICE_LIST 枚举;控制端改用 HTTP /api/client/bindings 拉取绑定设备。 + this.send({ type: 'DEVICE_LIST' }); } sendOffer(sdp, toDeviceId, authType = null, authValue = null) { const msg = { type: 'OFFER', - fromDeviceId: this.deviceId, toDeviceId, deviceType: 'CONTROLLER', payload: JSON.stringify({ sdp }), @@ -102,7 +111,6 @@ export class SignalingClient { }; this.send({ type: 'ICE_CANDIDATE', - fromDeviceId: this.deviceId, toDeviceId, deviceType: 'CONTROLLER', payload: JSON.stringify(payload), @@ -118,7 +126,7 @@ export class SignalingClient { disconnect() { this._stopHeartbeat(); if (this.ws) { - this.ws.close(); + try { this.ws.close(1000, 'Disconnecting'); } catch { /* ignore */ } this.ws = null; } } diff --git a/WebRTCControllerWeb/src/store/controllerStore.js b/WebRTCControllerWeb/src/store/controllerStore.js index 268cbb0..eca7c4b 100644 --- a/WebRTCControllerWeb/src/store/controllerStore.js +++ b/WebRTCControllerWeb/src/store/controllerStore.js @@ -3,33 +3,30 @@ import { SignalingClient } from '../services/SignalingClient'; import { WebRtcController } from '../services/WebRtcController'; import { VideoRecorder } from '../services/VideoRecorder'; import { loadProto } from '../proto/controlMessage'; +import * as api from '../services/ApiClient'; // 远程视频录制器(基于浏览器原生 MediaRecorder)。 const videoRecorder = new VideoRecorder(); -// 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。 -export const DEFAULT_ICE_SERVERS = [ - // 公共 TURN(relay 兜底):UDP + TCP 两种传输,TCP 用于 UDP 被防火墙拦截的网络。 +// 默认 ICE 兜底(当服务端未返回 TURN 凭证时使用,仅 STUN,可能穿透失败)。 +export const FALLBACK_ICE_SERVERS = [ { urls: 'stun:175.178.213.60:3478' }, - { urls: 'turn:175.178.213.60:3478', username: 'fanhuitong', credential: 'Fan19961207..' }, - { urls: 'turn:175.178.213.60:3478?transport=tcp', username: 'fanhuitong', credential: 'Fan19961207..' }, { urls: 'stun:47.242.112.133:3478' }, - { urls: 'turn:47.242.112.133:3478', username: 'ttstd', credential: 'fanhuitong' }, - // 内网 TURN(与被控端同局域网时可用)。 - { urls: 'stun:192.168.5.224:3478' }, - { urls: 'turn:192.168.5.224:3478', username: 'tt', credential: 'fht' }, - // { urls: 'turn:192.168.100.224:3478', username: 'tt', credential: 'fht' }, - // { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' }, - // { urls: 'stun:stun.l.google.com:19302' }, - // { urls: 'stun:stun1.l.google.com:19302' }, - // { urls: 'stun:stun2.l.google.com:19302' }, ]; export const store = reactive({ serverUrl: 'wss://www.ttstd.com/signal', + + // 登录态 + loggedIn: api.isLoggedIn(), + username: '', + loginError: '', + loginBusy: false, + + // 本机设备 ID 由服务端 REGISTER_SUCCESS 下发,无需用户填写。 deviceId: '', targetDeviceId: '', - iceServers: DEFAULT_ICE_SERVERS, + iceServers: FALLBACK_ICE_SERVERS, protoReady: false, signalingConnected: false, @@ -43,10 +40,8 @@ export const store = reactive({ controlledDevices: [], stats: null, remoteStream: null, - // 被控端上报的当前实际采集分辨率(宽/高/帧率),用于与分辨率下拉框保持一致。 currentResolution: null, - // 远程视频录制状态 recording: false, recordStatus: '', }); @@ -59,28 +54,51 @@ export async function initProto() { store.protoReady = true; } -function parsePayload(payload) { - if (!payload) return {}; - if (typeof payload === 'string') { - try { return JSON.parse(payload); } catch { return {}; } +/* ---------------- 登录 / 登出 ---------------- */ + +export async function login(username, password) { + store.loginBusy = true; + store.loginError = ''; + try { + const data = await api.login(username, password); + store.loggedIn = true; + store.username = data.username || username; + return true; + } catch (e) { + store.loginError = e.message || '登录失败'; + return false; + } finally { + store.loginBusy = false; } - return payload; } -export function connectSignaling() { +export async function register(username, password) { + return api.register({ username, password }); +} + +export async function logout() { + await api.logout().catch(() => {}); + store.loggedIn = false; + store.username = ''; + disconnectSignaling(); +} + +/* ---------------- 信令连接(Bearer 由子协议携带) ---------------- */ + +export async function connectSignaling() { store.error = ''; - const deviceId = store.deviceId.trim(); - if (!deviceId) { store.error = '请填写本机设备 ID'; return; } + if (!api.isLoggedIn()) { store.error = '请先登录'; return; } store.signalingConnecting = true; signaling = new SignalingClient({ serverUrl: store.serverUrl.trim(), - deviceId, - onConnected: () => { + onConnected: async () => { store.signalingConnected = true; store.signalingConnecting = false; store.statusText = '已连接信令服务器'; - refreshDevices(); + // 登录态下自动拉取一次 TURN 凭证与绑定设备列表。 + await loadTurnCredentials(); + await loadBindings(); }, onDisconnected: () => { store.signalingConnected = false; @@ -93,20 +111,74 @@ export function connectSignaling() { store.error = '信令错误: ' + e; }, onMessage: handleSignalMessage, + // 4001:尝试刷新令牌后重连。 + onTokenExpired: async () => { + try { + await api.refresh(); + reconnectSignaling(); + } catch { + await forceLogout('令牌已失效,请重新登录'); + } + }, + // 4003:强制下线。 + onForceLogout: async (reason) => { + await forceLogout(reason || '账号已在其他位置登录'); + }, }); signaling.connect(); } +// 刷新令牌后重连(保留已登录态)。 +function reconnectSignaling() { + store.statusText = '令牌已刷新,正在重连...'; + if (signaling) { signaling.disconnect(); signaling = null; } + connectSignaling(); +} + +async function forceLogout(reason) { + store.error = reason; + await api.logout().catch(() => {}); + store.loggedIn = false; + store.username = ''; + disconnectSignaling(); +} + +// 拉取 TURN 短期凭证(服务端开启时覆盖默认 ICE)。 +export async function loadTurnCredentials() { + const data = await api.fetchTurnCredentials(); + if (data && Array.isArray(data.iceServers) && data.iceServers.length) { + store.iceServers = data.iceServers; + } else { + store.iceServers = FALLBACK_ICE_SERVERS; + } + return store.iceServers; +} + +// 拉取本机可连接的被控端(仅已绑定设备)。 +export async function loadBindings() { + try { + const data = await api.listBindings(); + const list = (data && data.bindings) || []; + // 列表元素可能为 {deviceUid, alias, online} 或纯字符串。 + store.controlledDevices = list.map((b) => + typeof b === 'string' ? b : (b.deviceUid || b.deviceId || '') + ).filter(Boolean); + } catch { + store.controlledDevices = []; + } +} + function handleSignalMessage(msg) { switch ((msg.type || '').toUpperCase()) { case 'REGISTER_SUCCESS': store.registered = true; + store.deviceId = msg.fromDeviceId || store.deviceId; store.statusText = '注册成功 (CONTROLLER)'; break; case 'DEVICE_LIST': { + // 兜底:若服务端仍推送 DEVICE_LIST(兼容老逻辑)。 const list = msg.controlled || []; store.controlledDevices = Array.isArray(list) ? list : []; - if (!store.controlledDevices.includes(store.targetDeviceId)) store.targetDeviceId = ''; break; } case 'ANSWER': { @@ -135,7 +207,7 @@ function handleSignalMessage(msg) { } export function refreshDevices() { - signaling && signaling.requestDeviceList(); + loadBindings(); } export async function connectToDevice(targetId, authType = null, authValue = null) { @@ -145,12 +217,10 @@ export async function connectToDevice(targetId, authType = null, authValue = nul store.targetDeviceId = targetId; store.error = ''; store.statusText = '正在发起连接...'; - // 清空上一次连接的录制提示。 store.recording = false; store.recordStatus = ''; if (webrtc) { - // 切换目标设备会结束当前控制,若正在录制则先自动保存。 if (videoRecorder.recording) await stopRecording(); await webrtc.close(); webrtc = null; @@ -170,13 +240,10 @@ export async function connectToDevice(targetId, authType = null, authValue = nul onStream: (stream) => { store.remoteStream = markRaw(stream); }, onStats: (stats) => { store.stats = stats; }, onError: (msg) => { if (msg) store.error = msg; }, - // 被控端上报当前实际采集分辨率,同步到 store 供 UI 展示/匹配预设。 onResolutionReport: (res) => { store.currentResolution = res; }, }); await webrtc.createOffer(authType, authValue); } catch (e) { - // 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、 - // 间接影响 WebSocket 信令连接的存活。 console.error('[connectToDevice] 创建 Offer 失败:', e); store.error = '创建连接失败: ' + (e?.message || e); store.statusText = '连接失败'; @@ -185,7 +252,6 @@ export async function connectToDevice(targetId, authType = null, authValue = nul } export async function disconnectDevice() { - // 结束控制时若正在录制,先停止并自动保存(触发浏览器下载),再关闭连接。 if (videoRecorder.recording) { await stopRecording(); } @@ -195,19 +261,15 @@ export async function disconnectDevice() { store.remoteStream = null; store.stats = null; store.currentResolution = null; - // 注意:保留 recordStatus 的"已保存:文件名"提示,便于用户确认自动保存结果; - // 仅复位 recording 标志,下次连接开始时再清空提示。 store.recording = false; store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接'; } -// 切换远程视频录制:开始 / 停止。 export function toggleRecording() { if (store.recording) return stopRecording(); return startRecording(); } -// 开始录制当前远端视频流。 export function startRecording() { if (store.recording) return; if (!store.remoteStream) { @@ -230,12 +292,10 @@ export function startRecording() { } } -// 停止录制并触发浏览器下载。 export async function stopRecording() { const result = await videoRecorder.stop(); store.recording = false; if (result) { - // 通过 Blob URL 触发浏览器下载(纯前端唯一"保存"途径)。 const a = document.createElement('a'); a.href = result.url; a.download = result.filename; @@ -243,7 +303,6 @@ export async function stopRecording() { a.click(); document.body.removeChild(a); store.recordStatus = '已保存:' + result.filename; - // 下载完成后延迟释放 ObjectURL,避免下载被浏览器中断。 setTimeout(() => URL.revokeObjectURL(result.url), 30000); } else { store.recordStatus = '录制已停止'; @@ -266,5 +325,12 @@ export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.se export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); } export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); } export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); } -// 请求被控端切换屏幕采集分辨率(width<=0 表示原生分辨率;height<=0 时按 width 长边依宽高比缩放) export function sendResolutionChange(width, height, fps) { return webrtc && webrtc.sendResolutionChange(width, height, fps); } + +function parsePayload(payload) { + if (!payload) return {}; + if (typeof payload === 'string') { + try { return JSON.parse(payload); } catch { return {}; } + } + return payload; +} diff --git a/WebRTCSignalServer/SECURITY.md b/WebRTCSignalServer/SECURITY.md new file mode 100644 index 0000000..9be8198 --- /dev/null +++ b/WebRTCSignalServer/SECURITY.md @@ -0,0 +1,218 @@ +# WebRTCSignalServer 鉴权与账号机制说明 + +本文档描述已落地的 **WebSocket 握手鉴权** 与 **账号机制**(方案 P0 阶段)。 + +> **重大变更**:`/ws/signal` 不再接受匿名连接。所有客户端必须先获取访问令牌, +> 并在握手时携带,否则握手将以 HTTP 401 被拒绝。 + +--- + +## 1. 身份模型 + +两端能力不对称,因此采用双轨身份: + +| | 主控端 | 被控端 | +|---|---|---| +| 身份根 | 用户账号(用户名 + 密码) | 设备 SN(系统签名应用可靠获取) | +| 凭据 | accessToken + refreshToken | deviceSecret → 短期 deviceToken | +| 信令 ID | `ctl_`(服务端派生) | `dev_<22位随机>`(高熵不可枚举) | +| 信令角色 | `CONTROLLER` | `CONTROLLED` | + +**关键设计**:SN 只作服务端内部主键,绝不作为公网可寻址 ID 暴露; +对外一律使用随机生成的 `deviceUid`,杜绝通过猜测 SN 定位并骚扰被控端。 + +--- + +## 2. 主控端接入流程 + +``` +POST /api/auth/register { username, password } # 可关闭 +POST /api/auth/login { username, password } # -> accessToken / refreshToken + ↓ +WebSocket 握手(携带 accessToken) + ↓ +POST /api/auth/refresh { refreshToken } # accessToken 过期前刷新 +``` + +### 接口一览 + +| 方法 | 路径 | 说明 | 需认证 | +|---|---|---|---| +| POST | `/api/auth/register` | 注册(受 `ACCOUNT_REGISTRATION_ENABLED` 控制) | 否 | +| POST | `/api/auth/login` | 登录,返回令牌对 | 否 | +| POST | `/api/auth/refresh` | 刷新并轮转令牌 | 否 | +| POST | `/api/auth/logout` | 登出当前会话 | 是 | +| POST | `/api/auth/logout-all` | 全端登出 | 是 | +| POST | `/api/auth/change-password` | 改密(成功后强制全端重登) | 是 | +| GET | `/api/auth/me` | 查询当前身份 | 是 | +| GET | `/api/auth/sessions` | 查询本账号活跃会话 | 是 | + +认证方式:`Authorization: Bearer ` + +--- + +## 3. 被控端接入流程 + +被控端无法登录账号,改用「SN + 内置共享密钥 HMAC」激活: + +``` +POST /api/device/provision { sn, model, nonce, timestamp, hmac } + ↓ 返回 deviceUid + deviceSecret(仅此一次明文返回) + ↓ deviceSecret 存入 Android Keystore +POST /api/device/token { deviceUid, deviceSecret } + ↓ 返回短期 deviceToken(默认 15 分钟) +WebSocket 握手(携带 deviceToken) +``` + +### HMAC 计算方式 + +``` +hmac = HexLowercase( HMAC-SHA256( DEVICE_PROVISION_SECRET, sn + "|" + nonce + "|" + timestamp ) ) +``` + +- `timestamp`:Unix 秒;与服务端偏差超过 `provision-skew-seconds`(默认 300s)即拒绝 +- `nonce`:每次激活唯一(建议 UUID),服务端做重放检测 +- `DEVICE_PROVISION_SECRET`:内置于系统签名 APK,建议配合 R8/NDK 加固 + +激活安全校验链:`时间戳窗口 → nonce 防重放 → HMAC 签名 → SN 白名单` + +同一 SN 重复激活会**轮换 deviceSecret 并使旧令牌立即失效**(记录 WARN 日志)。 + +--- + +## 4. WebSocket 握手鉴权 + +### 令牌传递方式(按优先级) + +1. **推荐**(不会被写入访问日志): + ``` + Sec-WebSocket-Protocol: signal.v1, auth. + ``` + 服务端会回显 `signal.v1` 完成协商,**绝不回显携带令牌的那一项**。 + +2. 非浏览器客户端: + ``` + Authorization: Bearer + ``` + +3. 兼容方式(**不推荐**,令牌可能进入网关/代理日志,服务端会打印告警): + ``` + /ws/signal?token= + ``` + +### 服务端行为 + +- 握手阶段完成鉴权,失败直接返回 **401**,不建立连接 +- 单 IP 握手限流:60 秒内最多 30 次,超限返回 **429** +- 认证通过后,连接建立即自动完成注册并下发 `REGISTER_SUCCESS`, + **客户端无需再发送 `REGISTER`**(旧客户端仍发送时会收到同样的响应,不会报错) +- 消息中的 `fromDeviceId` / `deviceType` **一律被服务端鉴权结果覆盖**, + 客户端伪造无效(不一致时记录 WARN 日志) + +### 关闭码 + +| 码 | 含义 | +|---|---| +| 4001 | 未认证 / 认证信息缺失 | +| 4003 | 被强制下线(封禁、踢出、设备禁用) | + +被强制下线前,服务端会先下发一条消息: +```json +{ "type": "FORCE_LOGOUT", "payload": "<原因>" } +``` + +--- + +## 5. 封禁与强制下线 + +令牌失效采用 **凭据版本号(tokenVersion)** 机制:账号/设备被封禁时版本号递增, +所有已签发的令牌**立即失效**,无需维护黑名单表。同时服务端主动关闭其 WebSocket 连接。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/admin/users` | 账号列表 | +| GET | `/api/admin/users/{userId}/sessions` | 账号活跃会话 | +| POST | `/api/admin/users/{userId}/ban` | 封禁(`durationSeconds` 缺省为永久) | +| POST | `/api/admin/users/{userId}/unban` | 解封 | +| POST | `/api/admin/users/{userId}/kick` | 全端强制下线(不改封禁状态) | +| POST | `/api/admin/sessions/{sessionId}/kick` | 踢出单个会话 | +| GET | `/api/admin/device-accounts` | 设备列表(SN 已脱敏) | +| POST | `/api/admin/device-accounts/{uid}/disable` | 禁用设备 | +| POST | `/api/admin/device-accounts/{uid}/enable` | 启用设备 | +| POST | `/api/admin/device-allowlist` | 批量导入 SN 白名单 | + +管理接口凭据(二选一): +- `X-Admin-Token: `(兼容既有管理后台) +- `Authorization: Bearer ` 且账号具备管理员角色 + +--- + +## 6. 已实现的安全措施 + +| 措施 | 说明 | +|---|---| +| 密码存储 | BCrypt(cost=12) | +| 防账号枚举 | 用户名不存在时执行伪哈希抹平时间差,错误提示统一 | +| 登录防爆破 | 连续失败 5 次锁定 15 分钟;单 IP 5 分钟最多 10 次 | +| 刷新令牌轮转 | 每次刷新更换令牌;**检测到旧令牌复用即判定泄露并吊销整个会话** | +| 令牌用途隔离 | access / refresh / device 三类令牌互不通用 | +| 签名比较 | 使用 `MessageDigest.isEqual` 常量时间比较,防时序侧信道 | +| 并发会话限制 | 默认最多 5 个,超限自动踢最旧会话 | +| 激活防重放 | 时间戳窗口 + nonce 唯一性校验 | +| 日志脱敏 | SN 仅打印后 4 位 | +| 错误信息模糊化 | 对外统一提示,详细原因仅记录服务端日志 | +| 空闲超时 | 90 秒(原 10 分钟),尽早回收失联连接 | +| Origin 收敛 | 由 `WS_ALLOWED_ORIGINS` 配置,不再硬编码 `*` | + +--- + +## 7. 配置项 + +**生产环境必须通过环境变量配置以下三项:** + +| 环境变量 | 说明 | +|---|---| +| `JWT_SECRET` | JWT 签名密钥,**至少 32 字节**。未配置时随机生成,重启后令牌全部失效且多实例无法互认 | +| `DEVICE_PROVISION_SECRET` | 设备激活共享密钥。**未配置时所有激活请求会被拒绝** | +| `ADMIN_TOKEN` | 管理后台令牌 | + +其他可选项: + +| 环境变量 | 默认值 | 说明 | +|---|---|---| +| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | 初始管理员用户名 | +| `BOOTSTRAP_ADMIN_PASSWORD` | 空 | 初始管理员密码,**为空则不创建账号** | +| `ACCOUNT_REGISTRATION_ENABLED` | `true` | 是否开放自助注册 | +| `DEVICE_SN_ALLOWLIST_ENABLED` | `false` | 是否启用 SN 白名单 | +| `WS_ALLOWED_ORIGINS` | `*` | WebSocket 允许来源,生产应收敛为具体域名 | + +--- + +## 8. 客户端适配清单(待办) + +服务端已强制鉴权,以下客户端需相应改造,否则将无法连接: + +| 客户端 | 需要的改动 | +|---|---| +| `WebRTCControlled`(被控端) | 获取 SN → 调用 provision → 存储 deviceSecret → 换取 token → 握手携带 | +| `WebRTCController`(主控端 Android) | 增加登录界面 → 保存令牌 → 握手携带 → 处理 401/4003 | +| `WebRTCControllerWeb` | 同上,浏览器端建议使用 `Sec-WebSocket-Protocol` 方式 | +| `webrtc_controller_flutter` | 同上 | +| `webrtc_controller_ios` | 同上 | + +各端还需统一处理: +- `FORCE_LOGOUT` 消息与 4001 / 4003 关闭码(提示用户并跳转登录) +- 访问令牌过期前静默刷新(建议在剩余 1/3 有效期时触发) +- 不再发送 `REGISTER`(发送也不会出错,但已无意义) + +--- + +## 9. 当前实现边界 + +- **存储为内存实现**:`AccountService` / `DeviceIdentityService` 使用 + `ConcurrentHashMap`,**服务重启后账号与设备数据会丢失**。接口已按持久化预留, + 后续可替换为 MySQL 仓储而不影响调用方。 +- **限流为单机实现**:多实例部署需替换为 Redis 计数,并通过 Pub/Sub 广播踢线事件。 +- **尚未实现**(属方案 P1/P2 阶段):绑定关系寻址、配对码、被控端拉黑、 + 勿扰时段、审计日志、TOTP、TURN 短期凭据。 + 当前 `DEVICE_LIST` 仍返回全部在线设备,**接入绑定关系后应改为仅返回已绑定设备**。 diff --git a/WebRTCSignalServer/docs/CLIENT_API.md b/WebRTCSignalServer/docs/CLIENT_API.md new file mode 100644 index 0000000..8908b92 --- /dev/null +++ b/WebRTCSignalServer/docs/CLIENT_API.md @@ -0,0 +1,700 @@ +# 客户端对接接口规范 v1 + +面向 5 个客户端:`WebRTCControlled`(被控端)、`WebRTCController`(主控端 Android)、 +`WebRTCControllerWeb`、`webrtc_controller_flutter`、`webrtc_controller_ios`。 + +> **破坏性变更**:`/ws/signal` 不再接受匿名连接。未携带有效令牌的握手将被 **HTTP 401** 拒绝。 +> 各端必须完成本文档的改造才能连接。 + +--- + +## 0. 基础约定 + +### 0.1 地址 + +现有各端硬编码的信令地址为 `wss://www.ttstd.com/signal`(经反向代理)。 +新增的 HTTP 接口位于同域下的 `/api/**`: + +| 用途 | 地址 | +|---|---| +| HTTP API 基址 | `https://www.ttstd.com` | +| WebSocket 信令 | `wss://www.ttstd.com/signal` | + +> **运维需确认**:反向代理需将 `/api/**` 转发到信令服务的 `/api/**`, +> 且 `/signal` 转发到 `/ws/signal`。若代理仅配置了 `/signal`,需补充 `/api` 规则。 + +建议各端把「HTTP 基址」和「WS 地址」拆成两个可配置项,而非从 WS 地址推导。 + +### 0.2 通用响应 + +成功:`200 OK`,JSON body。 + +失败: +```json +{ "code": 401, "error": "UNAUTHORIZED", "message": "认证失败" } +``` + +> 服务端对外只返回**模糊提示**(不区分"账号不存在/密码错误"、"设备离线/被拉黑"), +> 详细原因仅记录在服务端日志。客户端不要试图解析 message 做业务分支,**请以 `error` 字段为准**。 + +### 0.3 通用错误码 + +| HTTP | error | 客户端应对 | +|---|---|---| +| 400 | `BAD_REQUEST` | 参数问题,提示用户修正 | +| 401 | `UNAUTHORIZED` | 令牌无效/过期 → 尝试刷新,失败则跳登录 | +| 403 | `FORBIDDEN` | 账号被封禁/设备被禁用 → 提示并登出 | +| 429 | `TOO_MANY_REQUESTS` | 触发限流 → 退避重试,勿立即重连 | + +--- + +## 1. 主控端接口(Controller / Web / Flutter / iOS) + +### 1.1 注册 + +``` +POST /api/auth/register +Content-Type: application/json + +{ "username": "alice", "password": "Passw0rd!" } +``` + +响应 `200`: +```json +{ "userId": "usr_xxxxxxxxxxxxxxxxxxxx", "username": "alice" } +``` + +约束: +- 用户名:3-32 位,`[a-zA-Z0-9_.-]` +- 密码:8-128 位,且至少包含「大写 / 小写 / 数字 / 符号」中的两类 +- 限流:单 IP 每小时 5 次 +- 可由服务端配置关闭(`ACCOUNT_REGISTRATION_ENABLED=false`),关闭时返回 403 + +--- + +### 1.2 登录 + +``` +POST /api/auth/login + +{ "username": "alice", "password": "Passw0rd!" } +``` + +响应 `200`: +```json +{ + "accessToken": "eyJhbGc...", + "refreshToken": "ses_xxxx.AbCdEf...", + "expiresIn": 900, + "sessionId": "ses_xxxxxxxxxxxxxxxxxxxx", + "principalId": "usr_xxxxxxxxxxxxxxxxxxxx", + "displayName": "alice" +} +``` + +要点: +- `accessToken` 有效期 **900 秒**,用于 HTTP 与 WebSocket 握手 +- `refreshToken` 有效期 **7 天**,格式为 `.`,**必须整体保存** +- 限流:单 IP 5 分钟内 10 次;连续密码错误 5 次锁定账号 15 分钟(返回 429) + +> **开启 TOTP 的账号**:若响应为 `401` 且 `error` 为 `TOTP_REQUIRED`, +> 需引导用户输入 6 位动态码,并带 `totpCode` 字段重新请求登录。详见 §1.8。 + +**存储要求**: + +| 端 | accessToken | refreshToken | +|---|---|---| +| Android | 内存 | EncryptedSharedPreferences | +| iOS | 内存 | **Keychain**(勿用 UserDefaults) | +| Flutter | 内存 | `flutter_secure_storage` | +| Web | 内存变量 | `sessionStorage`(勿用 localStorage) | + +--- + +### 1.3 刷新令牌 + +``` +POST /api/auth/refresh + +{ "refreshToken": "ses_xxxx.AbCdEf..." } +``` + +响应结构同登录。 + +**关键规则(务必遵守)**: +- 刷新令牌**一次性**,每次刷新都会返回新的,**必须立即覆盖保存旧值** +- 服务端有**复用检测**:若用已被使用过的旧 refreshToken 再次请求, + 会判定为令牌泄露并**吊销整个会话**(该账号在此设备上需重新登录) +- 因此**严禁并发刷新**。请加互斥锁/单飞(single-flight), + 多个请求同时遇到 401 时只允许一个发起刷新,其余等待其结果 + +**刷新时机**:建议在 `accessToken` 剩余有效期不足 1/3(即约 300 秒)时主动刷新, +不要等到 401 才被动刷新(WebSocket 握手失败重连成本更高)。 + +--- + +### 1.4 登出 + +``` +POST /api/auth/logout +Authorization: Bearer +``` + +``` +POST /api/auth/logout-all # 全端登出 +Authorization: Bearer +``` + +响应:`{ "success": true }` + +登出后应清空本地令牌并断开 WebSocket。 + +--- + +### 1.5 修改密码 + +``` +POST /api/auth/change-password +Authorization: Bearer + +{ "oldPassword": "...", "newPassword": "..." } +``` + +响应:`{ "success": true, "message": "密码已修改,请重新登录" }` + +> 成功后服务端会**吊销全部会话**,客户端必须清空令牌并跳转登录页。 + +--- + +### 1.6 查询当前身份 + +``` +GET /api/auth/me +Authorization: Bearer +``` + +```json +{ + "principalId": "usr_xxx", + "principalType": "USER", + "displayName": "alice", + "sessionId": "ses_xxx", + "signalDeviceId": "ctl_ses_xxx", + "admin": false +} +``` + +`signalDeviceId` 即该连接在信令网络中的 ID,**由服务端派生,客户端不可自定义**。 + +--- + +### 1.7 会话管理 + +``` +GET /api/auth/sessions +Authorization: Bearer +``` + +```json +[ + { + "sessionId": "ses_xxx", + "ip": "1.2.3.4", + "userAgent": "okhttp/4.12.0", + "createdAt": 1730000000000, + "lastSeenAt": 1730000600000, + "current": true + } +] +``` + +可用于「登录设备管理」界面,配合 §1.4 的 logout-all 实现异地下线。 + +--- + +### 1.8 TOTP 双因子(可选启用) + +``` +POST /api/auth/totp/setup # 生成密钥,返回 otpauth:// URI 供扫码 +Authorization: Bearer +``` +```json +{ "secret": "JBSWY3DP...", "otpauthUri": "otpauth://totp/...", "notice": "..." } +``` + +``` +POST /api/auth/totp/enable # 输入一次动态码完成绑定 +{ "code": "123456" } +``` + +``` +POST /api/auth/totp/disable # 需当前密码 + 动态码 +{ "password": "...", "code": "123456" } +``` + +启用后,`/api/auth/login` 需附带 `totpCode`: +```json +{ "username": "alice", "password": "Passw0rd!", "totpCode": "123456" } +``` + +--- + +### 1.9 令牌校验(建立长连前的预检) + +客户端在发起 WebSocket 握手前,可先调用此接口确认令牌未过期 / 未失效, +避免握手阶段被服务端直接断开(关闭码 4001)。用户令牌与设备令牌均可使用。 + +``` +GET /api/client/verify +Authorization: Bearer +``` + +有效时响应 `200`: +```json +{ + "valid": true, + "principalType": "DEVICE", // USER 或 DEVICE + "principalId": "dev_xxx", + "displayName": "Pixel 3", + "expiresAt": 1730000900, // 令牌过期时间(秒) + "remainingSeconds": 812, // 剩余有效秒数 + "serverTime": 1730000088 +} +``` + +无效时响应 `200`(HTTP 仍为 200,业务字段辨状态,避免泄露令牌是否存在): +```json +{ "valid": false, "error": "UNAUTHORIZED", "message": "认证失败" } +``` + +> 客户端策略:进入前台或准备重连时调用;若 `remainingSeconds` 低于 `accessTokenTtlSeconds * 0.33`, +> 用户端走 `/api/auth/refresh`,设备端重新执行 `/api/device/token`。 + +--- + +### 1.10 设备自助信息(被控端) + +被控端用自身设备令牌查询当前状态与在线情况(需 `DEVICE` 令牌)。 + +``` +GET /api/client/device/me +Authorization: Bearer +``` + +```json +{ + "deviceUid": "dev_xxx", + "model": "Pixel 3", + "status": "ACTIVE", // ACTIVE / SUSPENDED / BANNED + "usable": true, // 是否被禁用(false 时无法建立信令连接) + "provisionedAt": 1729000000, + "lastOnlineAt": 1730000088, + "statusReason": null // 被封禁/暂停原因(如有) +} +``` + +> 若 `usable == false`,客户端应停止尝试连接并提示用户设备已被禁用。 + +--- + +### 1.11 握手指引(自配置) + +返回 WebSocket 握手所需的地址、子协议、关闭码与刷新阈值,便于各端在无硬编码的前提下自配置。 + +``` +GET /api/client/ws-info +``` + +```json +{ + "wsPath": "/ws/signal", + "subprotocol": "signal.v1", + "tokenMethods": [ + "header:Authorization Bearer ", + "subprotocol:Sec-WebSocket-Protocol: signal.v1, auth.", + "query:?token=" + ], + "closeCodes": { "NORMAL": 1000, "UNAUTHORIZED": 4001, "FORCE_LOGOUT": 4003 }, + "idleTimeoutSeconds": 90, + "recommendedRefreshRatio": 0.33, + "accessTokenTtlSeconds": 900, + "deviceTokenTtlSeconds": 900 +} +``` + +--- + +### 1.12 绑定关系与黑名单(防骚扰核心) + +被控端(设备)与主控端(用户账号)之间**必须先建立绑定**,主控端才能向其发起连接(OFFER)。 +即便已绑定,被控端仍可将某主控端加入**黑名单**,黑名单优先级高于绑定,OFFER 会被服务端直接拒绝。 + +**默认行为(服务端强制)**: +- 未绑定 → OFFER 立即被拒,回送 `REQUEST_ERROR`:`"未与该设备建立绑定关系,无法发起连接"`。 +- 已拉黑 → OFFER 立即被拒,回送 `REQUEST_ERROR`:`"该设备已拒绝来自你的连接"`。 +- `DEVICE_LIST` 不再返回全局设备清单,主控端仅能看到自己已绑定设备的在线状态(消除被控端枚举)。 + +#### 被控端自助管理(设备令牌,`Authorization: Bearer `) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/api/client/device/bind` | 按用户名绑定某主控端:`{"username":"alice","alias":"客厅电视"}` | +| POST | `/api/client/device/unbind` | 解绑:`{"username":"alice"}` | +| POST | `/api/client/device/blacklist` | 拉黑:`{"username":"alice","reason":"骚扰"}` | +| POST | `/api/client/device/unblacklist` | 解除拉黑:`{"username":"alice"}` | +| GET | `/api/client/device/relations` | 查看本设备的绑定与黑名单列表 | + +#### 主控端自助查询(用户令牌) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/client/bindings` | 查看自己已绑定的设备及其在线状态 | + +#### 管理员接口(`/api/admin`,需管理员令牌) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/admin/devices/{deviceUid}/bindings` | 设备全部绑定关系 | +| GET | `/api/admin/devices/{deviceUid}/blacklist` | 设备黑名单 | +| POST | `/api/admin/devices/{deviceUid}/bind` | 建立绑定:`{"username","role":"MEMBER|OWNER","alias"}` | +| POST | `/api/admin/devices/{deviceUid}/unbind` | 解绑:`{"username"}` | +| POST | `/api/admin/devices/{deviceUid}/blacklist` | 拉黑:`{"username","reason"}` | +| POST | `/api/admin/devices/{deviceUid}/unblacklist` | 解除拉黑:`{"username"}` | + +> 绑定由「被控端自助」或「管理员」创建;解绑为软删除(置 `REVOKED`),重新绑定可恢复。 + +--- + +### 1.13 配对码、TURN 凭证与骚扰举报 + +#### 配对码(建立绑定的用户友好入口) +被控端生成一次性配对码(8 位、去除易混淆字符、10 分钟有效、单次使用、错误 5 次失效),主控端输入码即建立绑定(无需管理员介入)。 + +| 方法 | 路径 | 令牌 | 说明 | +| --- | --- | --- | --- | +| POST | `/api/client/device/pairing-code` | 设备 | 生成配对码,返回 `{"code":"A2B9K7M4","expiresInSeconds":600}` | +| POST | `/api/client/pairing/redeem` | 用户 | 兑换:`{"code":"A2B9K7M4"}`,成功返回绑定信息 | + +```bash +# 被控端生成 +curl -X POST https://www.ttstd.com/api/client/device/pairing-code \ + -H "Authorization: Bearer $DEVICE_TOKEN" + +# 主控端兑换 +curl -X POST https://www.ttstd.com/api/client/pairing/redeem \ + -H "Authorization: Bearer $USER_TOKEN" \ + -d '{"code":"A2B9K7M4"}' +``` + +#### TURN 短期凭证(中继,RFC 7635 风格) +客户端在 `new RTCPeerConnection` 前调用,获取时限内有效的 TURN 用户名/口令(HMAC-SHA1 由服务端密钥签名,到期需重新获取)。是否启用由服务端 `security.turn.enabled` 决定;`/api/client/ws-info` 的 `turnEnabled` 字段告知客户端。 + +| 方法 | 路径 | 令牌 | 说明 | +| --- | --- | --- | --- | +| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `iceServers`(含 urls/username/credential)、`expiresAt`、`ttlSeconds` | + +```json +{ + "iceServers": [ + { "urls": "turn:turn.ttstd.com:3478?transport=udp", + "username": "1730000000:ab12cd34ef56", + "credential": "Base64(HMAC-SHA1(sharedSecret, username))" } + ], + "expiresAt": 1730003600, + "ttlSeconds": 3600 +} +``` + +> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。 + +#### 骚扰举报(P2 风控) +被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。 + +| 方法 | 路径 | 令牌 | 说明 | +| --- | --- | --- | --- | +| POST | `/api/client/device/report` | 设备 | 举报:`{"username":"alice","reason":"持续骚扰"}` | +| GET | `/api/admin/abuse-reports` | 管理员 | 列表,支持 `status=PENDING` / `reportedUserId=` 过滤 | +| POST | `/api/admin/abuse-reports/{id}/handle` | 管理员 | 处理:`{"status":"HANDLED"}` 或 `DISMISSED` | + +--- + +## 2. 被控端接口(WebRTCControlled) + +被控端为系统签名应用(`android:sharedUserId="android.uid.system"`), +可通过 `DeviceUtils.getSerial()` 稳定获取 SN,但无法登录账号。 +因此采用「SN + 内置密钥 HMAC」激活。 + +### 2.1 首次激活 + +``` +POST /api/device/provision + +{ + "sn": "ABC123456789", + "model": "Pixel 3", + "nonce": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": 1730000000, + "hmac": "3f2a...(64 位小写 hex)" +} +``` + +**HMAC 计算**: +``` +hmac = HexLowercase( HMAC-SHA256( DEVICE_PROVISION_SECRET, sn + "|" + nonce + "|" + timestamp ) ) +``` +- `timestamp`:Unix **秒**(非毫秒),与服务端偏差超过 300 秒即拒绝 +- `nonce`:每次请求唯一(建议 UUID),服务端做重放检测 +- `DEVICE_PROVISION_SECRET`:内置于被控端 APK,建议放 NDK 层并配合 R8 混淆 + +响应 `200`: +```json +{ + "deviceUid": "dev_xxxxxxxxxxxxxxxxxxxxxx", + "deviceSecret": "AbCdEf...", + "notice": "deviceSecret 仅返回一次,请立即安全存储" +} +``` + +> **`deviceSecret` 仅此一次明文返回**,必须立即写入 Android Keystore +> (或 EncryptedSharedPreferences)。丢失后只能重新激活。 + +**幂等性**:同一 SN 重复激活会复用同一 `deviceUid`,但会**轮换 deviceSecret 并使旧令牌立即失效**。 +因此客户端应先检查本地是否已有 `deviceSecret`,**有则跳过激活**,避免自己把自己踢下线。 + +限流:单 IP 每小时 10 次。 + +### 2.2 换取访问令牌 + +``` +POST /api/device/token + +{ "deviceUid": "dev_xxx", "deviceSecret": "AbCdEf..." } +``` + +```json +{ + "accessToken": "eyJhbGc...", + "refreshToken": null, + "expiresIn": 900, + "principalId": "dev_xxx", + "displayName": "Pixel 3" +} +``` + +设备令牌**没有 refreshToken**,过期后直接用 `deviceSecret` 重新换取即可。 +建议在剩余有效期不足 1/3 时提前换取。 + +限流:单 IP 每小时 60 次。 + +### 2.3 被控端启动流程 + +``` +读取本地 deviceSecret + ├─ 不存在 → getSerial() → POST /api/device/provision → 存储 deviceUid + deviceSecret + └─ 已存在 → 跳过 + ↓ +POST /api/device/token → accessToken + ↓ +WebSocket 握手(携带 accessToken) + ↓ +收到 REGISTER_SUCCESS,其中 deviceId 即服务端分配的 deviceUid +``` + +> **重要**:被控端界面上原本让用户手输的「设备ID」输入框应当**移除或改为只读展示**。 +> 设备身份现在完全由 SN 激活决定,不再由用户输入。 + +--- + +## 3. WebSocket 握手改造(所有端) + +### 3.1 令牌传递方式 + +**方式 A —— 推荐(Web 端必用)**:子协议 +``` +Sec-WebSocket-Protocol: signal.v1, auth. +``` +服务端会回显 `signal.v1` 完成协商,**不会回显含令牌的那一项**。 +优点:令牌不进入 URL,不会被网关/代理写入访问日志。 + +**方式 B —— 原生客户端可用**:请求头 +``` +Authorization: Bearer +``` + +**方式 C —— 兼容,不推荐**:查询参数 +``` +wss://www.ttstd.com/signal?token= +``` +服务端会打印告警。仅在前两种都无法实现时使用。 + +### 3.2 各端实现要点 + +**Android(OkHttp)** — 两端通用: +```java +Request request = new Request.Builder() + .url(serverUrl) + .addHeader("Authorization", "Bearer " + accessToken) + .build(); +client.newWebSocket(request, listener); +``` + +**Web(浏览器 WebSocket)** — 浏览器无法设置请求头,**必须用子协议**: +```js +const ws = new WebSocket(serverUrl, ['signal.v1', 'auth.' + accessToken]); +``` + +**Flutter(web_socket_channel)**: +```dart +// IOWebSocketChannel 支持 headers(移动端) +final channel = IOWebSocketChannel.connect( + Uri.parse(serverUrl), + headers: {'Authorization': 'Bearer $accessToken'}, +); +// 若需兼容 Flutter Web,改用子协议: +// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.$accessToken']); +``` + +**iOS(URLSessionWebSocketTask)**: +```swift +var request = URLRequest(url: url) +request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") +let task = session.webSocketTask(with: request) +``` + +### 3.3 连接建立后 + +**不再需要发送 `REGISTER`**。认证通过后服务端自动完成注册并主动下发: + +```json +{ + "type": "REGISTER_SUCCESS", + "deviceId": "ctl_ses_xxx", + "deviceType": "CONTROLLER", + "displayName": "alice" +} +``` + +客户端应以此消息中的 `deviceId` 作为自己的信令 ID。 + +> 旧客户端继续发送 `REGISTER` 不会报错(服务端仅回显同样的响应),但已无实际意义。 + +### 3.4 身份字段不再可信 + +消息中的 `fromDeviceId` 和 `deviceType` **会被服务端用鉴权结果强制覆盖**。 +客户端填写任意值都无效(不一致时服务端记录 WARN 日志)。发送消息时可继续填写,也可省略。 + +### 3.5 关闭码处理 + +| 关闭码 | 含义 | 客户端应对 | +|---|---|---| +| 4001 | 未认证 / 令牌无效 | 刷新令牌后重连;刷新失败则跳登录 | +| 4003 | 被强制下线(封禁/踢出/设备禁用) | **停止自动重连**,提示用户,清空令牌跳登录 | +| 1000/1001 | 正常关闭 | 按既有逻辑 | +| 其他 | 网络异常 | 指数退避重连 | + +**特别注意**:被控端 `WebRTCControlled` 现有的自动重连是指数退避(2s 起,上限 30s)。 +收到 **4003** 时必须**关闭自动重连**,否则会造成无效重连风暴。 + +### 3.6 FORCE_LOGOUT 消息 + +服务端在强制断开前会先下发: +```json +{ "type": "FORCE_LOGOUT", "payload": "账号已被封禁:违规操作" } +``` +客户端应展示 `payload` 给用户,然后清理本地状态。 + +### 3.7 握手限流 + +单 IP **60 秒内最多 30 次**握手,超限返回 **429**。 +各端重连必须使用指数退避,避免触发。 + +--- + +## 4. 各端改造清单 + +### 4.1 WebRTCControlled(被控端) + +| 项 | 说明 | +|---|---| +| 新增 | HTTP 客户端(复用已有 OkHttp)调用 provision / token | +| 新增 | `DeviceUtils.getSerial()` 接入激活流程(代码已存在,当前被注释) | +| 新增 | Keystore / EncryptedSharedPreferences 存储 `deviceUid` + `deviceSecret` | +| 新增 | 令牌过期前自动换取 | +| 修改 | `WebSocketClient` 构造增加 token,握手加 `Authorization` 头 | +| 修改 | 移除/只读化「设备ID」输入框(`activity_main.xml` 中硬编码的 `981964879` 应删除) | +| 修改 | 收到 4003 时停止自动重连 | +| 修改 | 服务器地址持久化(当前每次启动重置) | + +### 4.2 WebRTCController(主控端 Android) + +| 项 | 说明 | +|---|---| +| 新增 | 登录界面(作为 launcher,登录后再进主界面) | +| 新增 | HTTP 客户端调用 login / refresh | +| 新增 | EncryptedSharedPreferences 存 refreshToken | +| 新增 | 令牌自动刷新(加单飞锁) | +| 修改 | `WebSocketClient` 握手加 `Authorization` 头 | +| 修改 | 移除「设备ID」输入框,改用 `REGISTER_SUCCESS` 返回值 | +| 修改 | **补充心跳与重连**(当前完全没有,仅被控端有) | +| 修改 | 处理 4001 / 4003 / FORCE_LOGOUT | + +### 4.3 WebRTCControllerWeb + +| 项 | 说明 | +|---|---| +| 新增 | 登录页组件 + `authStore`(沿用现有 `reactive` 风格,无需引入 Pinia) | +| 新增 | `fetch` 封装(当前无任何 HTTP 能力),含 401 自动刷新 | +| 新增 | `sessionStorage` 存 refreshToken | +| 修改 | `SignalingClient.js` 用**子协议**传令牌(浏览器无法设请求头) | +| 修改 | 移除 `App.vue` 中 `'web-' + random` 的临时 deviceId 生成 | +| 修改 | 处理关闭码与 FORCE_LOGOUT | + +### 4.4 webrtc_controller_flutter + +| 项 | 说明 | +|---|---| +| 新增依赖 | `http`(或 `dio`)+ `flutter_secure_storage` | +| 新增 | 登录页 + 令牌管理 | +| 修改 | `signaling_client.dart` 改用 `IOWebSocketChannel.connect(..., headers:)` | +| 修改 | 移除 `DeviceUtils.getSerialNumber()` 作为 deviceId 的逻辑(主控端身份来自账号) | +| 修改 | 处理关闭码与 FORCE_LOGOUT | + +### 4.5 webrtc_controller_ios + +| 项 | 说明 | +|---|---| +| 新增 | 登录视图 + `AuthService`(URLSession HTTP) | +| 新增 | **Keychain** 存 refreshToken(当前 `DeviceUtils` 用的是 UserDefaults,不适合存凭据) | +| 修改 | `SignalingClient.swift` 用 `URLRequest` + `Authorization` 头 | +| 修改 | 移除 `DeviceUtils.deviceId()` 作为信令 ID 的逻辑 | +| 修改 | 处理关闭码与 FORCE_LOGOUT | + +--- + +## 5. 联调顺序建议 + +1. **先确认反向代理**已放行 `/api/**` +2. 服务端配置 `JWT_SECRET`、`DEVICE_PROVISION_SECRET`、`BOOTSTRAP_ADMIN_PASSWORD` +3. 用 curl 验证 login / provision 流程跑通 +4. 改造**被控端**(无 UI 依赖,最容易验证) +5. 改造**Web 端**(调试最快,可验证子协议方式) +6. 改造 Android 主控端 → Flutter → iOS + +### curl 自测示例 + +```bash +# 注册 +curl -X POST https://www.ttstd.com/api/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"username":"tester","password":"Passw0rd!"}' + +# 登录 +curl -X POST https://www.ttstd.com/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"tester","password":"Passw0rd!"}' + +# 设备激活(timestamp 用当前 Unix 秒,hmac 需按 §2.1 计算) +curl -X POST https://www.ttstd.com/api/device/provision \ + -H 'Content-Type: application/json' \ + -d '{"sn":"TESTSN001","model":"test","nonce":"'$(uuidgen)'","timestamp":'$(date +%s)',"hmac":"..."}' +``` diff --git a/WebRTCSignalServer/docs/schema.sql b/WebRTCSignalServer/docs/schema.sql new file mode 100644 index 0000000..5c9d830 --- /dev/null +++ b/WebRTCSignalServer/docs/schema.sql @@ -0,0 +1,284 @@ +-- ============================================================= +-- WebRTCSignalServer 数据库建表脚本 (MySQL 8.0+) +-- +-- 【请手动执行,服务端代码当前仍为内存实现,不会自动建表】 +-- +-- 执行方式: +-- mysql -u root -p < schema.sql +-- +-- 字符集统一使用 utf8mb4 / utf8mb4_0900_ai_ci +-- 所有时间字段使用 DATETIME(3),由应用层写入 UTC 时间 +-- ============================================================= + +CREATE DATABASE IF NOT EXISTS `webrtc_signal` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_0900_ai_ci; + +USE `webrtc_signal`; + + +-- ============================================================= +-- 1. 主控端用户账号 +-- ============================================================= +CREATE TABLE IF NOT EXISTS `app_user` ( + `user_id` VARCHAR(32) NOT NULL COMMENT '用户ID,格式 usr_<20位Base62>', + `username` VARCHAR(32) NOT NULL COMMENT '登录名', + `password_hash` VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希(cost=12)', + `status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' + COMMENT '账号状态:ACTIVE/SUSPENDED/BANNED', + `status_until` DATETIME(3) NULL COMMENT '临时封禁到期时间,NULL 表示永久或未封禁', + `status_reason` VARCHAR(255) NULL COMMENT '封禁原因', + `token_version` BIGINT NOT NULL DEFAULT 1 + COMMENT '凭据版本号,递增后所有已签发令牌立即失效', + `is_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否管理员', + + -- 双因子认证 + `totp_secret` VARCHAR(64) NULL COMMENT 'TOTP 密钥(Base32),NULL 表示未启用', + `totp_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'TOTP 是否已完成绑定', + + -- 防爆破 + `failed_attempts` INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + `locked_until` DATETIME(3) NULL COMMENT '锁定截止时间', + + `last_login_at` DATETIME(3) NULL, + `last_login_ip` VARCHAR(64) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`user_id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB COMMENT='主控端用户账号'; + + +-- ============================================================= +-- 2. 密码历史(防止重复使用近期密码) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `password_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` VARCHAR(32) NOT NULL, + `password_hash` VARCHAR(100) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`), + KEY `idx_user_created` (`user_id`, `created_at` DESC), + CONSTRAINT `fk_pwdhist_user` FOREIGN KEY (`user_id`) + REFERENCES `app_user` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='密码历史,建议保留最近 5 条'; + + +-- ============================================================= +-- 3. 登录会话(刷新令牌上下文) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `login_session` ( + `session_id` VARCHAR(32) NOT NULL COMMENT '会话ID,格式 ses_<20位Base62>', + `principal_id` VARCHAR(32) NOT NULL COMMENT '主体ID(user_id)', + `principal_type` VARCHAR(16) NOT NULL DEFAULT 'USER' COMMENT 'USER/DEVICE', + `refresh_token_hash` VARCHAR(64) NOT NULL COMMENT '当前刷新令牌的 SHA-256,轮转后更新', + `refresh_expires_at` DATETIME(3) NOT NULL, + `ip` VARCHAR(64) NULL, + `user_agent` VARCHAR(256) NULL, + `revoked` TINYINT(1) NOT NULL DEFAULT 0, + `revoked_reason` VARCHAR(255) NULL, + `last_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`session_id`), + KEY `idx_principal` (`principal_id`, `revoked`), + KEY `idx_expires` (`refresh_expires_at`) +) ENGINE=InnoDB COMMENT='登录会话,支持踢线与刷新令牌轮转'; + + +-- ============================================================= +-- 4. SN 白名单(出厂/部署时批量导入) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `allowed_device` ( + `sn` VARCHAR(64) NOT NULL COMMENT '设备序列号', + `batch` VARCHAR(64) NULL COMMENT '批次标识', + `note` VARCHAR(255) NULL, + `imported_by` VARCHAR(64) NULL, + `imported_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`sn`), + KEY `idx_batch` (`batch`) +) ENGINE=InnoDB COMMENT='允许激活的 SN 白名单'; + + +-- ============================================================= +-- 5. 被控端设备 +-- SN 仅作内部主键,对外一律使用高熵不可枚举的 device_uid +-- ============================================================= +CREATE TABLE IF NOT EXISTS `device` ( + `device_uid` VARCHAR(32) NOT NULL COMMENT '对外设备ID,格式 dev_<22位Base62>', + `sn` VARCHAR(64) NOT NULL COMMENT '设备序列号(内部使用,勿对外暴露)', + `secret_hash` VARCHAR(64) NOT NULL COMMENT 'deviceSecret 的 SHA-256', + `model` VARCHAR(64) NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' + COMMENT 'ACTIVE/SUSPENDED/BANNED', + `status_until` DATETIME(3) NULL, + `status_reason` VARCHAR(255) NULL, + `token_version` BIGINT NOT NULL DEFAULT 1, + + -- 防骚扰配置(P1 阶段使用) + `strict_mode` TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '严格模式:仅允许已绑定账号连接', + `quiet_start` TIME NULL COMMENT '勿扰时段开始', + `quiet_end` TIME NULL COMMENT '勿扰时段结束', + + `provisioned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `last_online_at` DATETIME(3) NULL, + + PRIMARY KEY (`device_uid`), + UNIQUE KEY `uk_sn` (`sn`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB COMMENT='被控端设备身份'; + + +-- ============================================================= +-- 6. 设备激活 nonce(防重放) +-- 建议配合定时任务清理过期记录,或改用 Redis +-- ============================================================= +CREATE TABLE IF NOT EXISTS `provision_nonce` ( + `nonce` VARCHAR(64) NOT NULL, + `sn` VARCHAR(64) NULL, + `expires_at` DATETIME(3) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`nonce`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB COMMENT='激活请求 nonce,用于防重放'; + + +-- ============================================================= +-- 7. 设备绑定关系(P1 阶段:主控端唯一寻址凭证) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `device_binding` ( + `binding_id` VARCHAR(36) NOT NULL COMMENT 'UUID', + `device_uid` VARCHAR(32) NOT NULL, + `user_id` VARCHAR(32) NOT NULL, + `role` VARCHAR(16) NOT NULL DEFAULT 'OPERATOR' + COMMENT 'OWNER/OPERATOR/VIEWER', + `alias` VARCHAR(64) NULL COMMENT '主控端自定义备注名', + `status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/REVOKED', + `bound_by` VARCHAR(32) NULL, + `bound_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `expire_at` DATETIME(3) NULL COMMENT '绑定过期时间,NULL 表示长期有效', + + PRIMARY KEY (`binding_id`), + UNIQUE KEY `uk_device_user` (`device_uid`, `user_id`), + KEY `idx_user_status` (`user_id`, `status`), + KEY `idx_device_status` (`device_uid`, `status`) +) ENGINE=InnoDB COMMENT='设备与账号绑定关系'; + + +-- ============================================================= +-- 8. 配对码(P1 阶段:建立绑定) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `pairing_code` ( + `code_hash` VARCHAR(64) NOT NULL COMMENT '配对码的 SHA-256', + `device_uid` VARCHAR(32) NOT NULL, + `attempts` INT NOT NULL DEFAULT 0 COMMENT '错误尝试次数,超限即失效', + `used` TINYINT(1) NOT NULL DEFAULT 0, + `expires_at` DATETIME(3) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`code_hash`), + KEY `idx_device` (`device_uid`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB COMMENT='一次性配对码'; + + +-- ============================================================= +-- 9. 被控端黑名单(P1 阶段:拉黑骚扰账号) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `device_blacklist` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `device_uid` VARCHAR(32) NOT NULL, + `blocked_user_id` VARCHAR(32) NOT NULL, + `reason` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_device_user` (`device_uid`, `blocked_user_id`), + KEY `idx_device` (`device_uid`) +) ENGINE=InnoDB COMMENT='被控端黑名单'; + + +-- ============================================================= +-- 10. 连接会话记录(WebRTC 通话审计) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `connection_session` ( + `session_id` VARCHAR(36) NOT NULL, + `binding_id` VARCHAR(36) NULL, + `device_uid` VARCHAR(32) NOT NULL, + `user_id` VARCHAR(32) NOT NULL, + `auth_type` VARCHAR(16) NULL COMMENT 'NONE/CODE/PASSWORD', + `started_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `ended_at` DATETIME(3) NULL, + `end_reason` VARCHAR(64) NULL, + + PRIMARY KEY (`session_id`), + KEY `idx_device_started` (`device_uid`, `started_at` DESC), + KEY `idx_user_started` (`user_id`, `started_at` DESC) +) ENGINE=InnoDB COMMENT='远程控制会话记录'; + + +-- ============================================================= +-- 11. 审计日志(只追加,不修改) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `audit_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `actor_type` VARCHAR(16) NOT NULL COMMENT 'USER/DEVICE/ADMIN/SYSTEM', + `actor_id` VARCHAR(64) NULL, + `action` VARCHAR(64) NOT NULL + COMMENT 'LOGIN/LOGIN_FAILED/LOGOUT/REGISTER/PROVISION/BAN/UNBAN/KICK/BIND/UNBIND/BLACKLIST...', + `target_type` VARCHAR(16) NULL, + `target_id` VARCHAR(64) NULL, + `result` VARCHAR(16) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAILURE', + `ip` VARCHAR(64) NULL, + `user_agent` VARCHAR(256) NULL, + `detail` VARCHAR(512) NULL COMMENT '注意:敏感信息需脱敏后再写入', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`), + KEY `idx_actor` (`actor_id`, `created_at` DESC), + KEY `idx_action` (`action`, `created_at` DESC), + KEY `idx_created` (`created_at` DESC) +) ENGINE=InnoDB COMMENT='安全审计日志'; + + +-- ============================================================= +-- 12. 骚扰举报(P2 阶段风控) +-- ============================================================= +CREATE TABLE IF NOT EXISTS `abuse_report` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `device_uid` VARCHAR(32) NOT NULL COMMENT '举报方(被控端)', + `reported_user_id` VARCHAR(32) NOT NULL COMMENT '被举报账号', + `reason` VARCHAR(255) NULL, + `handled` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`), + KEY `idx_reported` (`reported_user_id`, `created_at` DESC), + KEY `idx_handled` (`handled`) +) ENGINE=InnoDB COMMENT='骚扰举报记录'; + + +-- ============================================================= +-- 清理建议(可配置为 MySQL Event 或应用层定时任务) +-- ============================================================= +-- DELETE FROM provision_nonce WHERE expires_at < NOW(); +-- DELETE FROM pairing_code WHERE expires_at < NOW(); +-- DELETE FROM login_session WHERE refresh_expires_at < NOW() OR revoked = 1; +-- DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY); + + +-- ============================================================= +-- 初始管理员账号 +-- ============================================================= +-- 不在此处插入明文密码。请通过环境变量启动服务自动创建: +-- BOOTSTRAP_ADMIN_USERNAME=admin +-- BOOTSTRAP_ADMIN_PASSWORD=<强密码> +-- 或注册后手动提升权限: +-- UPDATE app_user SET is_admin = 1 WHERE username = 'admin'; diff --git a/WebRTCSignalServer/pom.xml b/WebRTCSignalServer/pom.xml index 54c56e1..35105c6 100644 --- a/WebRTCSignalServer/pom.xml +++ b/WebRTCSignalServer/pom.xml @@ -34,11 +34,41 @@ com.fasterxml.jackson.core jackson-databind + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.mysql + mysql-connector-j + + + + org.springframework.boot + spring-boot-starter-data-redis + org.springframework.boot spring-boot-starter-test test + + + com.h2database + h2 + test + diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/AdminAuthFilter.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/AdminAuthFilter.java index 41941cd..ecfdb5b 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/AdminAuthFilter.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/AdminAuthFilter.java @@ -1,10 +1,13 @@ package com.ttstd.signaling.config; +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.security.TokenUtils; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -12,12 +15,21 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; /** - * 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口, - * 校验请求头 {@code X-Admin-Token} 是否与配置的令牌一致。 - * 令牌可通过 {@code admin.token} 配置项或环境变量 {@code ADMIN_TOKEN} 设置。 + * 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口。 + * + *

支持两种凭据,满足其一即可通过: + *

    + *
  1. 请求头 {@code X-Admin-Token} 与配置令牌一致(兼容既有管理后台登录方式)
  2. + *
  3. {@code Authorization: Bearer } 且账号具备管理员角色 + * (由 {@link BearerAuthFilter} 预先解析)
  4. + *
+ * + *

过滤器顺序在 {@link BearerAuthFilter} 之后,以便读取其解析出的主体。 */ @Component +@Order(2) public class AdminAuthFilter extends OncePerRequestFilter { + @Value("${admin.token:webrtc-admin-token}") private String adminToken; @@ -34,13 +46,25 @@ public class AdminAuthFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); return; } - String token = request.getHeader("X-Admin-Token"); - if (adminToken != null && !adminToken.isBlank() && adminToken.equals(token)) { + + if (hasValidAdminToken(request) || hasAdminPrincipal(request)) { filterChain.doFilter(request, response); - } else { - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员令牌\"}"); + return; } + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员凭据\"}"); + } + + private boolean hasValidAdminToken(HttpServletRequest request) { + String token = request.getHeader("X-Admin-Token"); + return adminToken != null && !adminToken.isBlank() + && TokenUtils.constantTimeEquals(adminToken, token); + } + + private boolean hasAdminPrincipal(HttpServletRequest request) { + Object principal = request.getAttribute(BearerAuthFilter.ATTR_PRINCIPAL); + return principal instanceof AuthPrincipal auth && auth.admin(); } } \ No newline at end of file diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/BearerAuthFilter.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/BearerAuthFilter.java new file mode 100644 index 0000000..d145645 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/BearerAuthFilter.java @@ -0,0 +1,106 @@ +package com.ttstd.signaling.config; + +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.service.AccountService; +import com.ttstd.signaling.service.DeviceIdentityService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Set; + +/** + * Bearer 令牌解析过滤器。 + * + *

解析 {@code Authorization: Bearer } 并将 {@link AuthPrincipal} 写入请求属性, + * 供受保护接口读取。对无需认证的公开端点直接放行; + * 对需要认证但令牌无效的请求返回 401。 + */ +@Component +@Order(1) +public class BearerAuthFilter extends OncePerRequestFilter { + + public static final String ATTR_PRINCIPAL = "authPrincipal"; + + /** 无需认证即可访问的端点 */ + private static final Set PUBLIC_PATHS = Set.of( + "/api/auth/register", + "/api/auth/login", + "/api/auth/refresh", + "/api/device/provision", + "/api/device/token"); + + /** 需要令牌的受保护端点前缀(用户或设备令牌均可) */ + private static final Set PROTECTED_PREFIXES = Set.of("/api/auth/", "/api/client/"); + + private final AccountService accountService; + private final DeviceIdentityService deviceIdentityService; + + public BearerAuthFilter(AccountService accountService, + DeviceIdentityService deviceIdentityService) { + this.accountService = accountService; + this.deviceIdentityService = deviceIdentityService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String uri = request.getRequestURI(); + + if ("OPTIONS".equalsIgnoreCase(request.getMethod()) || PUBLIC_PATHS.contains(uri)) { + filterChain.doFilter(request, response); + return; + } + + String token = extractBearer(request); + if (token != null) { + AuthPrincipal principal = resolve(token); + if (principal != null) { + request.setAttribute(ATTR_PRINCIPAL, principal); + } + } + + boolean requiresAuth = PROTECTED_PREFIXES.stream().anyMatch(uri::startsWith); + if (requiresAuth && request.getAttribute(ATTR_PRINCIPAL) == null) { + writeUnauthorized(response); + return; + } + + filterChain.doFilter(request, response); + } + + /** 依次尝试用户令牌与设备令牌。 */ + private AuthPrincipal resolve(String token) { + try { + return accountService.authenticate(token); + } catch (AuthException ignored) { + try { + return deviceIdentityService.authenticate(token); + } catch (AuthException ignored2) { + return null; + } + } + } + + private String extractBearer(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + String token = authorization.substring(7).trim(); + return token.isEmpty() ? null : token; + } + return null; + } + + private void writeUnauthorized(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":401,\"message\":\"认证失败\"}"); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java new file mode 100644 index 0000000..9958de7 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java @@ -0,0 +1,15 @@ +package com.ttstd.signaling.config; + +import com.ttstd.signaling.security.SecurityProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 启用安全相关配置属性绑定与定时任务。 + */ +@Configuration +@EnableConfigurationProperties(SecurityProperties.class) +@EnableScheduling +public class SecurityBeansConfig { +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java index 0839d24..19f6894 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/WebSocketConfig.java @@ -1,6 +1,9 @@ package com.ttstd.signaling.config; import com.ttstd.signaling.handler.SignalWebSocketHandler; +import com.ttstd.signaling.security.AuthHandshakeInterceptor; +import com.ttstd.signaling.security.SecurityProperties; +import com.ttstd.signaling.security.SubProtocolHandshakeHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; @@ -8,34 +11,54 @@ import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; +import java.util.Arrays; + @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final SignalWebSocketHandler signalWebSocketHandler; + private final AuthHandshakeInterceptor authHandshakeInterceptor; + private final SubProtocolHandshakeHandler handshakeHandler; + private final SecurityProperties securityProperties; - public WebSocketConfig(SignalWebSocketHandler signalWebSocketHandler) { + public WebSocketConfig(SignalWebSocketHandler signalWebSocketHandler, + AuthHandshakeInterceptor authHandshakeInterceptor, + SubProtocolHandshakeHandler handshakeHandler, + SecurityProperties securityProperties) { this.signalWebSocketHandler = signalWebSocketHandler; + this.authHandshakeInterceptor = authHandshakeInterceptor; + this.handshakeHandler = handshakeHandler; + this.securityProperties = securityProperties; } /** * 配置 WebSocket 容器的消息大小限制与会话超时。 *

默认文本消息缓冲区为 8KB,WebRTC SDP(OFFER/ANSWER)消息经 JSON 包装后 * 可能超过该限制,导致服务端抛出 TextMessageLimitException 并关闭连接。 - * 此处将文本消息上限设为 512KB,二进制消息上限设为 512KB,会话空闲超时设为 10 分钟。 + * 此处将文本消息上限设为 512KB,二进制消息上限设为 512KB。 + *

空闲超时设为 90 秒,配合客户端 PING 心跳,可尽早回收失联连接, + * 缩短被封禁账号残留连接的存活窗口。 */ @Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(512 * 1024); // 512 KB container.setMaxBinaryMessageBufferSize(512 * 1024); // 512 KB - container.setMaxSessionIdleTimeout(600_000L); // 10 分钟 + container.setMaxSessionIdleTimeout(90_000L); // 90 秒 return container; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + String[] origins = Arrays.stream(securityProperties.getWebsocket().getAllowedOrigins().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + registry.addHandler(signalWebSocketHandler, "/ws/signal") - .setAllowedOrigins("*"); + .setHandshakeHandler(handshakeHandler) + .addInterceptors(authHandshakeInterceptor) + .setAllowedOriginPatterns(origins); } } diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java index c803a08..39e309b 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java @@ -3,13 +3,27 @@ package com.ttstd.signaling.controller; import com.ttstd.signaling.manager.ConnectionRequestManager; import com.ttstd.signaling.manager.SessionManager; import com.ttstd.signaling.manager.SignalMetrics; +import com.ttstd.signaling.model.AbuseReport; +import com.ttstd.signaling.model.DeviceAccount; +import com.ttstd.signaling.model.DeviceBinding; import com.ttstd.signaling.model.DeviceInfo; +import com.ttstd.signaling.model.UserAccount; +import com.ttstd.signaling.service.AbuseReportService; +import com.ttstd.signaling.service.AccountService; +import com.ttstd.signaling.service.AuditService; +import com.ttstd.signaling.service.BindingService; +import com.ttstd.signaling.service.DeviceIdentityService; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.Instant; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -25,14 +39,29 @@ public class AdminController { private final SessionManager sessionManager; private final ConnectionRequestManager connectionRequestManager; private final SignalMetrics metrics; + private final AccountService accountService; + private final DeviceIdentityService deviceIdentityService; + private final AuditService auditService; + private final BindingService bindingService; + private final AbuseReportService abuseReportService; private final long startTime = System.currentTimeMillis(); public AdminController(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager, - SignalMetrics metrics) { + SignalMetrics metrics, + AccountService accountService, + DeviceIdentityService deviceIdentityService, + AuditService auditService, + BindingService bindingService, + AbuseReportService abuseReportService) { this.sessionManager = sessionManager; this.connectionRequestManager = connectionRequestManager; this.metrics = metrics; + this.accountService = accountService; + this.deviceIdentityService = deviceIdentityService; + this.auditService = auditService; + this.bindingService = bindingService; + this.abuseReportService = abuseReportService; } /** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */ @@ -99,4 +128,310 @@ public class AdminController { data.put("onlineDevices", sessionManager.getOnlineCount()); return data; } + + // ==================== 账号管理 ==================== + + /** 主控端账号列表。 */ + @GetMapping("/users") + public List> users() { + return accountService.listUsers().stream().map(AdminController::toUserView).toList(); + } + + /** 指定账号的活跃登录会话。 */ + @GetMapping("/users/{userId}/sessions") + public List> userSessions(@PathVariable String userId) { + return accountService.listSessions(userId).stream().map(s -> { + Map item = new LinkedHashMap<>(); + item.put("sessionId", s.getSessionId()); + item.put("ip", s.getIp()); + item.put("userAgent", s.getUserAgent()); + item.put("createdAt", s.getCreatedAt().toEpochMilli()); + item.put("lastSeenAt", s.getLastSeenAt().toEpochMilli()); + return item; + }).toList(); + } + + /** + * 封禁账号并立即下线其全部连接。 + * + *

请求体:{@code {"reason": "...", "durationSeconds": 3600}}, + * durationSeconds 缺省或 <=0 表示永久封禁。 + */ + @PostMapping("/users/{userId}/ban") + public Map banUser(@PathVariable String userId, + @RequestBody(required = false) Map body) { + String reason = body == null ? null : (String) body.get("reason"); + long duration = body == null ? 0 : toLong(body.get("durationSeconds")); + Instant until = duration > 0 ? Instant.now().plusSeconds(duration) : null; + accountService.ban(userId, until, reason); + return Map.of("success", true, "userId", userId, + "permanent", until == null); + } + + /** 解封账号。 */ + @PostMapping("/users/{userId}/unban") + public Map unbanUser(@PathVariable String userId) { + accountService.unban(userId); + return Map.of("success", true, "userId", userId); + } + + /** 强制账号全端下线(不改变封禁状态)。 */ + @PostMapping("/users/{userId}/kick") + public Map kickUser(@PathVariable String userId, + @RequestBody(required = false) Map body) { + String reason = body == null ? "管理员强制下线" : (String) body.getOrDefault("reason", "管理员强制下线"); + accountService.revokeAllSessions(userId, reason); + return Map.of("success", true, "userId", userId); + } + + /** 踢出指定登录会话。 */ + @PostMapping("/sessions/{sessionId}/kick") + public Map kickSession(@PathVariable String sessionId, + @RequestBody(required = false) Map body) { + String reason = body == null ? "管理员强制下线" : (String) body.getOrDefault("reason", "管理员强制下线"); + accountService.revokeSession(sessionId, reason); + return Map.of("success", true, "sessionId", sessionId); + } + + // ==================== 设备管理 ==================== + + /** 已激活的被控端设备列表(SN 已脱敏)。 */ + @GetMapping("/device-accounts") + public List> deviceAccounts() { + return deviceIdentityService.listDevices().stream().map(d -> { + Map item = new LinkedHashMap<>(); + item.put("deviceUid", d.getDeviceUid()); + item.put("sn", d.maskedSn()); + item.put("model", d.getModel()); + item.put("status", d.getStatus().name()); + item.put("statusReason", d.getStatusReason()); + item.put("provisionedAt", d.getProvisionedAt().toEpochMilli()); + item.put("lastOnlineAt", d.getLastOnlineAt() == null ? null : d.getLastOnlineAt().toEpochMilli()); + item.put("online", sessionManager.isDeviceOnline(d.getDeviceUid())); + return item; + }).toList(); + } + + /** 禁用设备并立即断开其连接。 */ + @PostMapping("/device-accounts/{deviceUid}/disable") + public Map disableDevice(@PathVariable String deviceUid, + @RequestBody(required = false) Map body) { + String reason = body == null ? null : (String) body.get("reason"); + long duration = body == null ? 0 : toLong(body.get("durationSeconds")); + Instant until = duration > 0 ? Instant.now().plusSeconds(duration) : null; + deviceIdentityService.disable(deviceUid, until, reason); + return Map.of("success", true, "deviceUid", deviceUid); + } + + /** 启用设备。 */ + @PostMapping("/device-accounts/{deviceUid}/enable") + public Map enableDevice(@PathVariable String deviceUid) { + deviceIdentityService.enable(deviceUid); + return Map.of("success", true, "deviceUid", deviceUid); + } + + /** 批量导入 SN 白名单。请求体:{@code {"sns": ["SN001", "SN002"]}} */ + @PostMapping("/device-allowlist") + public Map importAllowlist(@RequestBody Map body) { + Object raw = body.get("sns"); + if (!(raw instanceof List list)) { + return Map.of("success", false, "message", "缺少 sns 数组"); + } + List sns = list.stream().map(String::valueOf).toList(); + int added = deviceIdentityService.importAllowlist(sns); + return Map.of("success", true, "added", added, + "total", deviceIdentityService.getAllowlist().size()); + } + + // ==================== 绑定 / 黑名单管理 ==================== + + /** 列出某设备的全部绑定关系(含已撤销)。 */ + @GetMapping("/devices/{deviceUid}/bindings") + public List> deviceBindings(@PathVariable String deviceUid) { + return bindingService.listByDevice(deviceUid).stream().map(b -> { + Map item = new LinkedHashMap<>(); + item.put("bindingId", b.getBindingId()); + item.put("deviceUid", b.getDeviceUid()); + item.put("userId", b.getUserId()); + item.put("role", b.getRole().name()); + item.put("alias", b.getAlias()); + item.put("status", b.getStatus().name()); + item.put("boundAt", b.getBoundAt().toEpochMilli()); + return item; + }).toList(); + } + + /** 列出某设备的黑名单。 */ + @GetMapping("/devices/{deviceUid}/blacklist") + public List> deviceBlacklist(@PathVariable String deviceUid) { + return bindingService.listBlacklist(deviceUid).stream().map(e -> { + Map item = new LinkedHashMap<>(); + item.put("blockedUserId", e.getBlockedUserId()); + item.put("reason", e.getReason()); + item.put("createdAt", e.getCreatedAt().toEpochMilli()); + return item; + }).toList(); + } + + /** + * 管理员建立绑定。 + * 请求体:{@code {"username": "alice", "role": "MEMBER", "alias": "客厅电视"}}。 + */ + @PostMapping("/devices/{deviceUid}/bind") + public Map adminBind(@PathVariable String deviceUid, + @RequestBody Map body) { + String username = (String) body.get("username"); + if (username == null || username.isBlank()) { + return Map.of("success", false, "message", "缺少 username"); + } + String userId = bindingService.resolveUserId(username); + DeviceBinding.BindingRole role = parseRole(body.get("role")); + String alias = (String) body.get("alias"); + DeviceBinding binding = bindingService.bind(deviceUid, userId, role, alias, "admin"); + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("bindingId", binding.getBindingId()); + result.put("deviceUid", deviceUid); + result.put("userId", userId); + return result; + } + + /** 管理员解绑(按 username)。 */ + @PostMapping("/devices/{deviceUid}/unbind") + public Map adminUnbind(@PathVariable String deviceUid, + @RequestBody Map body) { + String username = (String) body.get("username"); + if (username == null || username.isBlank()) { + return Map.of("success", false, "message", "缺少 username"); + } + String userId = bindingService.resolveUserId(username); + bindingService.revokeBinding(deviceUid, userId, "admin"); + return Map.of("success", true, "deviceUid", deviceUid, "userId", userId); + } + + /** 管理员将某账号加入设备黑名单。请求体:{@code {"username": "alice", "reason": "骚扰"}}。 */ + @PostMapping("/devices/{deviceUid}/blacklist") + public Map adminBlacklist(@PathVariable String deviceUid, + @RequestBody Map body) { + String username = (String) body.get("username"); + if (username == null || username.isBlank()) { + return Map.of("success", false, "message", "缺少 username"); + } + String userId = bindingService.resolveUserId(username); + String reason = (String) body.get("reason"); + bindingService.addBlacklist(deviceUid, userId, reason, "admin"); + return Map.of("success", true, "deviceUid", deviceUid, "userId", userId); + } + + /** 管理员移除设备黑名单。请求体:{@code {"username": "alice"}}。 */ + @PostMapping("/devices/{deviceUid}/unblacklist") + public Map adminUnblacklist(@PathVariable String deviceUid, + @RequestBody Map body) { + String username = (String) body.get("username"); + if (username == null || username.isBlank()) { + return Map.of("success", false, "message", "缺少 username"); + } + String userId = bindingService.resolveUserId(username); + bindingService.removeBlacklist(deviceUid, userId, "admin"); + return Map.of("success", true, "deviceUid", deviceUid, "userId", userId); + } + + private static DeviceBinding.BindingRole parseRole(Object value) { + if (value == null) { + return DeviceBinding.BindingRole.MEMBER; + } + try { + return DeviceBinding.BindingRole.valueOf(value.toString().toUpperCase()); + } catch (IllegalArgumentException e) { + return DeviceBinding.BindingRole.MEMBER; + } + } + + // ==================== 骚扰举报管理 ==================== + + /** 列出举报。可按状态过滤,亦可按被举报用户过滤。 */ + @GetMapping("/abuse-reports") + public List listAbuseReports( + @RequestParam(required = false) String status, + @RequestParam(required = false) String reportedUserId) { + if (reportedUserId != null && !reportedUserId.isBlank()) { + return abuseReportService.listByReportedUser(reportedUserId); + } + if (status != null && !status.isBlank()) { + try { + AbuseReport.ReportStatus s = AbuseReport.ReportStatus.valueOf(status.toUpperCase()); + return abuseReportService.listByReportedUser(null).stream() + .filter(r -> r.getStatus() == s).toList(); + } catch (IllegalArgumentException ignored) { + // 落入默认 + } + } + return abuseReportService.listByReportedUser(null); + } + + /** 处理举报:标记 HANDLED / DISMISSED(并可联动封禁被举报账号)。 */ + @PostMapping("/abuse-reports/{id}/handle") + public Map handleAbuseReport(@PathVariable Long id, + @RequestBody Map body) { + String statusStr = (String) body.get("status"); + AbuseReport.ReportStatus status = AbuseReport.ReportStatus.HANDLED; + if (statusStr != null) { + try { + status = AbuseReport.ReportStatus.valueOf(statusStr.toUpperCase()); + } catch (IllegalArgumentException ignored) { + // 保持默认 + } + } + abuseReportService.handle(id, status); + Map r = new LinkedHashMap<>(); + r.put("success", true); + r.put("id", id); + r.put("status", status.name()); + return r; + } + + // ==================== 审计日志 ==================== + + /** + * 查询审计日志。 + * + *

支持可选过滤:{@code actorId}(账号/设备/管理员 ID)、 + * {@code action}(动作常量,如 LOGIN、BAN)、{@code result}(SUCCESS/FAILURE)。 + * 默认返回最近 100 条,最多 1000 条。 + */ + @GetMapping("/audit") + public List audit( + @RequestParam(defaultValue = "100") int limit, + @RequestParam(required = false) String actorId, + @RequestParam(required = false) String action, + @RequestParam(required = false) String result) { + int cap = Math.min(Math.max(limit, 1), 1000); + List all = auditService.recent(cap); + if (actorId == null && action == null && result == null) { + return all; + } + return all.stream() + .filter(e -> actorId == null || actorId.equals(e.actorId())) + .filter(e -> action == null || action.equals(e.action())) + .filter(e -> result == null || result.equals(e.result())) + .toList(); + } + + private static Map toUserView(UserAccount u) { + Map item = new LinkedHashMap<>(); + item.put("userId", u.getUserId()); + item.put("username", u.getUsername()); + item.put("status", u.getStatus().name()); + item.put("statusReason", u.getStatusReason()); + item.put("statusUntil", u.getStatusUntil() == null ? null : u.getStatusUntil().toEpochMilli()); + item.put("admin", u.isAdmin()); + item.put("locked", u.isLocked()); + item.put("createdAt", u.getCreatedAt().toEpochMilli()); + item.put("lastLoginAt", u.getLastLoginAt() == null ? null : u.getLastLoginAt().toEpochMilli()); + return item; + } + + private static long toLong(Object value) { + return value instanceof Number n ? n.longValue() : 0L; + } } diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AuthController.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AuthController.java new file mode 100644 index 0000000..873f11f --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AuthController.java @@ -0,0 +1,213 @@ +package com.ttstd.signaling.controller; + +import com.ttstd.signaling.controller.dto.AuthDtos; +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.model.UserAccount; +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.security.RateLimiter; +import com.ttstd.signaling.service.AccountService; +import com.ttstd.signaling.service.TokenPair; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 主控端账号认证接口。 + */ +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + + /** 登录限流:单 IP 5 分钟内最多 10 次 */ + private static final int LOGIN_LIMIT = 10; + private static final long LOGIN_WINDOW_SECONDS = 300; + + private final AccountService accountService; + private final RateLimiter rateLimiter; + + public AuthController(AccountService accountService, RateLimiter rateLimiter) { + this.accountService = accountService; + this.rateLimiter = rateLimiter; + } + + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody AuthDtos.RegisterRequest request, + HttpServletRequest httpRequest) { + String ip = clientIp(httpRequest); + if (!rateLimiter.tryAcquire("register:" + ip, 5, 3600)) { + throw AuthException.tooManyRequests("注册过于频繁,请稍后再试"); + } + UserAccount account = accountService.register(request.username(), request.password()); + Map body = new LinkedHashMap<>(); + body.put("userId", account.getUserId()); + body.put("username", account.getUsername()); + return ResponseEntity.ok(body); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody AuthDtos.LoginRequest request, + HttpServletRequest httpRequest) { + String ip = clientIp(httpRequest); + if (!rateLimiter.tryAcquire("login:" + ip, LOGIN_LIMIT, LOGIN_WINDOW_SECONDS)) { + throw AuthException.tooManyRequests("登录尝试过于频繁,请稍后再试"); + } + TokenPair pair = accountService.login( + request.username(), request.password(), request.totpCode(), + ip, userAgent(httpRequest)); + rateLimiter.reset("login:" + ip); + return ResponseEntity.ok(toResponse(pair)); + } + + @PostMapping("/refresh") + public ResponseEntity refresh(@Valid @RequestBody AuthDtos.RefreshRequest request, + HttpServletRequest httpRequest) { + TokenPair pair = accountService.refresh( + request.refreshToken(), clientIp(httpRequest), userAgent(httpRequest)); + return ResponseEntity.ok(toResponse(pair)); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + accountService.logout(principal.sessionId()); + return ResponseEntity.ok(Map.of("success", true)); + } + + @PostMapping("/logout-all") + public ResponseEntity> logoutAll(HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + accountService.revokeAllSessions(principal.principalId(), "用户主动退出全部设备"); + return ResponseEntity.ok(Map.of("success", true)); + } + + @PostMapping("/change-password") + public ResponseEntity> changePassword( + @Valid @RequestBody AuthDtos.ChangePasswordRequest request, + HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + accountService.changePassword(principal.principalId(), + request.oldPassword(), request.newPassword()); + return ResponseEntity.ok(Map.of("success", true, "message", "密码已修改,请重新登录")); + } + + // ==================== TOTP 双因子 ==================== + + /** + * 生成 TOTP 密钥。返回 otpauth URI 供客户端渲染二维码; + * 此时尚未生效,需再调用 /totp/enable 完成绑定。 + */ + @PostMapping("/totp/setup") + public ResponseEntity> totpSetup(HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + Map result = accountService.setupTotp(principal.principalId()); + Map body = new LinkedHashMap<>(); + body.put("secret", result.get("secret")); + body.put("otpauthUri", result.get("otpauthUri")); + body.put("notice", "请用认证器扫码后调用 /api/auth/totp/enable 提交动态码完成绑定"); + return ResponseEntity.ok(body); + } + + /** 提交一次动态码,正式启用双因子。 */ + @PostMapping("/totp/enable") + public ResponseEntity> totpEnable( + @Valid @RequestBody AuthDtos.TotpEnableRequest request, + HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + accountService.enableTotp(principal.principalId(), request.code()); + return ResponseEntity.ok(Map.of("success", true, "message", "双因子认证已启用")); + } + + /** 关闭双因子,需同时校验密码与动态码。 */ + @PostMapping("/totp/disable") + public ResponseEntity> totpDisable( + @Valid @RequestBody AuthDtos.TotpDisableRequest request, + HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + accountService.disableTotp(principal.principalId(), request.password(), request.code()); + return ResponseEntity.ok(Map.of("success", true, "message", "双因子认证已关闭")); + } + + /** 查询当前登录身份。 */ + @GetMapping("/me") + public ResponseEntity> me(HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + Map body = new LinkedHashMap<>(); + body.put("principalId", principal.principalId()); + body.put("principalType", principal.principalType().name()); + body.put("displayName", principal.displayName()); + body.put("sessionId", principal.sessionId()); + body.put("signalDeviceId", principal.deviceId()); + body.put("admin", principal.admin()); + // 便于客户端展示"是否已开启双因子",仅对用户身份有效 + if (principal.isUser()) { + body.put("totpEnabled", + accountService.requireUser(principal.principalId()).isTotpEnabled()); + } + return ResponseEntity.ok(body); + } + + /** 查询当前账号的活跃会话列表。 */ + @GetMapping("/sessions") + public ResponseEntity>> sessions(HttpServletRequest httpRequest) { + AuthPrincipal principal = requirePrincipal(httpRequest); + List> body = accountService.listSessions(principal.principalId()) + .stream() + .map(s -> { + Map item = new LinkedHashMap<>(); + item.put("sessionId", s.getSessionId()); + item.put("ip", s.getIp()); + item.put("userAgent", s.getUserAgent()); + item.put("createdAt", s.getCreatedAt().toEpochMilli()); + item.put("lastSeenAt", s.getLastSeenAt().toEpochMilli()); + item.put("current", s.getSessionId().equals(principal.sessionId())); + return item; + }) + .toList(); + return ResponseEntity.ok(body); + } + + private AuthDtos.TokenResponse toResponse(TokenPair pair) { + return new AuthDtos.TokenResponse( + pair.accessToken(), pair.refreshToken(), pair.expiresInSeconds(), + pair.sessionId(), pair.principalId(), pair.displayName()); + } + + private AuthPrincipal requirePrincipal(HttpServletRequest request) { + Object principal = request.getAttribute( + com.ttstd.signaling.config.BearerAuthFilter.ATTR_PRINCIPAL); + if (principal instanceof AuthPrincipal auth) { + return auth; + } + throw AuthException.unauthorized("缺少有效的访问令牌"); + } + + static String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + int comma = forwarded.indexOf(','); + return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim(); + } + return request.getRemoteAddr(); + } + + static String userAgent(HttpServletRequest request) { + String ua = request.getHeader("User-Agent"); + if (ua == null) { + return "unknown"; + } + return ua.length() > 256 ? ua.substring(0, 256) : ua; + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AuthExceptionHandler.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AuthExceptionHandler.java new file mode 100644 index 0000000..eafb0a2 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AuthExceptionHandler.java @@ -0,0 +1,45 @@ +package com.ttstd.signaling.controller; + +import com.ttstd.signaling.security.AuthException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 统一异常处理:仅向客户端返回模糊提示,详细原因只记录在服务端日志, + * 避免通过错误信息差异探测账号是否存在、设备是否在线等。 + */ +@RestControllerAdvice +public class AuthExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(AuthExceptionHandler.class); + + @ExceptionHandler(AuthException.class) + public ResponseEntity> handleAuth(AuthException ex) { + logger.warn("认证/授权失败 [{}]: {}", ex.getCode(), ex.getMessage()); + Map body = new LinkedHashMap<>(); + body.put("code", ex.getStatus()); + body.put("error", ex.getCode()); + body.put("message", ex.getPublicMessage()); + return ResponseEntity.status(ex.getStatus()).body(body); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(e -> e.getDefaultMessage()) + .orElse("请求参数不合法"); + Map body = new LinkedHashMap<>(); + body.put("code", 400); + body.put("error", "BAD_REQUEST"); + body.put("message", message); + return ResponseEntity.badRequest().body(body); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/ClientController.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/ClientController.java new file mode 100644 index 0000000..74db55c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/ClientController.java @@ -0,0 +1,368 @@ +package com.ttstd.signaling.controller; + +import com.ttstd.signaling.config.BearerAuthFilter; +import com.ttstd.signaling.model.AccountStatus; +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.model.DeviceAccount; +import com.ttstd.signaling.model.DeviceBinding; +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.service.AbuseReportService; +import com.ttstd.signaling.service.BindingService; +import com.ttstd.signaling.service.DeviceIdentityService; +import com.ttstd.signaling.service.PairingService; +import com.ttstd.signaling.service.TurnCredentialService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 客户端自助接口:供 WebRTCController / WebRTCControlled / Web / Flutter / iOS 五端对接所用。 + * + *

所有接口均需在请求中携带有效令牌({@code Authorization: Bearer }), + * 由 {@link BearerAuthFilter} 统一解析并写入主体信息;令牌缺失或非法将返回 401。 + * + *

包含: + *

    + *
  • {@code /verify} —— 令牌校验(用户/设备令牌通用),返回有效性、过期时间与剩余秒数; + *
  • {@code /device/me} —— 设备自助信息,被控端查看自身状态与在线情况; + *
  • {@code /account/me} —— 账号自助信息,主控端查看自身状态与双因子开关; + *
  • {@code /ws-info} —— 握手指引,返回 WebSocket 地址、子协议与关闭码,便于客户端自配置。 + *
+ */ +@RestController +@RequestMapping("/api/client") +public class ClientController { + + private final JwtService jwtService; + private final SecurityProperties properties; + private final DeviceIdentityService deviceIdentityService; + private final BindingService bindingService; + private final PairingService pairingService; + private final TurnCredentialService turnCredentialService; + private final AbuseReportService abuseReportService; + + public ClientController(JwtService jwtService, + SecurityProperties properties, + DeviceIdentityService deviceIdentityService, + BindingService bindingService, + PairingService pairingService, + TurnCredentialService turnCredentialService, + AbuseReportService abuseReportService) { + this.jwtService = jwtService; + this.properties = properties; + this.deviceIdentityService = deviceIdentityService; + this.bindingService = bindingService; + this.pairingService = pairingService; + this.turnCredentialService = turnCredentialService; + this.abuseReportService = abuseReportService; + } + + // ==================== 令牌校验 ==================== + + /** + * 校验调用方自身令牌的有效性。 + * + *

客户端在建立 WebSocket 长连前可先调用此接口确认令牌未过期/未失效, + * 避免握手阶段被直接断开。支持用户令牌与设备令牌。 + * + * @return valid 是否有效;若有效则附带 principalType、principalId、displayName、 + * expiresAt(秒级时间戳)、remainingSeconds(剩余有效秒数)。 + */ + @GetMapping("/verify") + public Map verify(HttpServletRequest request) { + AuthPrincipal principal = requirePrincipal(request); + String token = extractBearer(request); + + Map claims; + try { + // 不限定用途:用户 access 与设备 device 令牌均可用于 HTTP 鉴权 + claims = jwtService.verify(token, null); + } catch (AuthException e) { + Map body = new LinkedHashMap<>(); + body.put("valid", false); + body.put("error", e.getCode()); + body.put("message", e.getMessage()); + return body; + } + + long exp = JwtService.claimAsLong(claims, "exp", 0L); + long now = Instant.now().getEpochSecond(); + long remaining = Math.max(0, exp - now); + + Map body = new LinkedHashMap<>(); + body.put("valid", true); + body.put("principalType", principal.principalType().name()); + body.put("principalId", principal.principalId()); + body.put("displayName", principal.displayName()); + body.put("expiresAt", exp); + body.put("remainingSeconds", remaining); + body.put("serverTime", now); + return body; + } + + // ==================== 设备自助信息 ==================== + + /** + * 被控端查看自身信息(需设备令牌)。 + */ + @GetMapping("/device/me") + public Map deviceMe(HttpServletRequest request) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可访问此接口"); + } + DeviceAccount device = deviceIdentityService.findByUid(principal.principalId()); + if (device == null) { + throw AuthException.notFound("设备不存在"); + } + + Map body = new LinkedHashMap<>(); + body.put("deviceUid", device.getDeviceUid()); + body.put("model", device.getModel()); + body.put("status", device.getStatus().name()); + body.put("usable", device.isUsable()); + body.put("provisionedAt", device.getProvisionedAt() == null ? null + : device.getProvisionedAt().getEpochSecond()); + body.put("lastOnlineAt", device.getLastOnlineAt() == null ? null + : device.getLastOnlineAt().getEpochSecond()); + if (device.getStatus() == AccountStatus.SUSPENDED && device.getStatusUntil() != null) { + body.put("statusUntil", device.getStatusUntil().getEpochSecond()); + } + body.put("statusReason", device.getStatusReason()); + return body; + } + + // ==================== 握手指引 ==================== + + /** + * 返回客户端建立 WebSocket 长连所需的握手参数,便于各端自配置。 + */ + @GetMapping("/ws-info") + public Map wsInfo() { + Map body = new LinkedHashMap<>(); + body.put("wsPath", "/ws/signal"); + body.put("subprotocol", "signal.v1"); + body.put("tokenMethods", new String[]{ + "header:Authorization Bearer ", + "subprotocol:Sec-WebSocket-Protocol: signal.v1, auth.", + "query:?token=" + }); + body.put("closeCodes", Map.of( + "NORMAL", 1000, + "UNAUTHORIZED", 4001, + "FORCE_LOGOUT", 4003)); + body.put("idleTimeoutSeconds", 90); + body.put("recommendedRefreshRatio", 0.33); + body.put("accessTokenTtlSeconds", properties.getJwt().getAccessTokenTtlSeconds()); + body.put("deviceTokenTtlSeconds", properties.getJwt().getDeviceTokenTtlSeconds()); + body.put("turnEnabled", turnCredentialService.isEnabled()); + return body; + } + + // ==================== 配对码(建立绑定) ==================== + + /** + * 被控端生成一次性配对码(明文仅回显一次)。主控端输入该码即可建立绑定。 + */ + @PostMapping("/device/pairing-code") + public Map generatePairingCode(HttpServletRequest request) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + String code = pairingService.generate(principal.principalId()); + Map r = new LinkedHashMap<>(); + r.put("code", code); + r.put("expiresInSeconds", 600); + return r; + } + + /** + * 主控端兑换配对码,建立与被控端的绑定关系。 + */ + @PostMapping("/pairing/redeem") + public Map redeemPairingCode(HttpServletRequest request, @RequestBody Map body) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isUser()) { + throw AuthException.forbidden("仅用户令牌可调用"); + } + String code = body.get("code"); + DeviceBinding binding = pairingService.redeem(code, principal.principalId()); + return bindingView(binding); + } + + // ==================== TURN 短期凭证 ==================== + + /** + * 获取 TURN 短期凭证(ICE servers)。主控端与被控端在建立 PeerConnection 前调用。 + */ + @GetMapping("/turn-credentials") + public Map turnCredentials(HttpServletRequest request) { + AuthPrincipal principal = requirePrincipal(request); + return turnCredentialService.issue(principal.principalType() + ":" + principal.principalId()); + } + + // ==================== 骚扰举报(P2 风控) ==================== + + /** + * 被控端举报某主控端账号骚扰。需提供被举报方用户名。 + */ + @PostMapping("/device/report") + public Map reportAbuse(HttpServletRequest request, @RequestBody Map body) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + String username = body.get("username"); + if (username == null || username.isBlank()) { + throw AuthException.badRequest("被举报用户名不能为空"); + } + String reportedUserId = bindingService.resolveUserId(username); + abuseReportService.report(principal.principalId(), reportedUserId, + body.getOrDefault("reason", "")); + Map r = new LinkedHashMap<>(); + r.put("ok", true); + return r; + } + + // ==================== 被控端自助管理(设备令牌) ==================== + + /** + * 被控端将某主控端账号加入绑定(允许其发起连接)。 + * 通过用户名解析主控端用户 ID,绑定由设备侧创建(OWNER 视为设备所有者)。 + */ + @PostMapping("/device/bind") + public Map deviceBind(HttpServletRequest request, @RequestBody Map body) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + String username = body.get("username"); + String alias = body.get("alias"); + String userId = bindingService.resolveUserId(username); + DeviceBinding binding = bindingService.bind( + principal.principalId(), userId, DeviceBinding.BindingRole.MEMBER, alias, + "device:" + principal.principalId()); + return bindingView(binding); + } + + /** 被控端解除某主控端账号的绑定。 */ + @PostMapping("/device/unbind") + public Map deviceUnbind(HttpServletRequest request, @RequestBody Map body) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + String username = body.get("username"); + String userId = bindingService.resolveUserId(username); + bindingService.revokeBinding(principal.principalId(), userId, "device:" + principal.principalId()); + Map r = new LinkedHashMap<>(); + r.put("ok", true); + return r; + } + + /** 被控端拉黑某主控端账号(优先级高于绑定,OFFER 将被服务端拒绝)。 */ + @PostMapping("/device/blacklist") + public Map deviceBlacklist(HttpServletRequest request, @RequestBody Map body) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + String username = body.get("username"); + String reason = body.get("reason"); + String userId = bindingService.resolveUserId(username); + bindingService.addBlacklist(principal.principalId(), userId, reason, "device:" + principal.principalId()); + Map r = new LinkedHashMap<>(); + r.put("ok", true); + return r; + } + + /** 被控端解除对某主控端账号的拉黑。 */ + @PostMapping("/device/unblacklist") + public Map deviceUnblacklist(HttpServletRequest request, @RequestBody Map body) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + String username = body.get("username"); + String userId = bindingService.resolveUserId(username); + bindingService.removeBlacklist(principal.principalId(), userId, "device:" + principal.principalId()); + Map r = new LinkedHashMap<>(); + r.put("ok", true); + return r; + } + + /** 被控端查看自身的绑定与黑名单列表。 */ + @GetMapping("/device/relations") + public Map deviceRelations(HttpServletRequest request) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isDevice()) { + throw AuthException.forbidden("仅设备令牌可调用"); + } + Map r = new LinkedHashMap<>(); + r.put("bindings", bindingService.listByDevice(principal.principalId()).stream() + .map(this::bindingView).toList()); + r.put("blacklist", bindingService.listBlacklist(principal.principalId()).stream() + .map(e -> Map.of( + "blockedUserId", e.getBlockedUserId(), + "reason", e.getReason() == null ? "" : e.getReason())) + .toList()); + return r; + } + + // ==================== 主控端自助查询(用户令牌) ==================== + + /** 主控端查看自己已绑定的设备及其在线状态。 */ + @GetMapping("/bindings") + public Map myBindings(HttpServletRequest request) { + AuthPrincipal principal = requirePrincipal(request); + if (!principal.isUser()) { + throw AuthException.forbidden("仅用户令牌可调用"); + } + Map r = new LinkedHashMap<>(); + r.put("bindings", bindingService.listByUser(principal.principalId()).stream() + .filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE) + .map(this::bindingView).toList()); + return r; + } + + private Map bindingView(DeviceBinding b) { + Map m = new LinkedHashMap<>(); + m.put("bindingId", b.getBindingId()); + m.put("deviceUid", b.getDeviceUid()); + m.put("userId", b.getUserId()); + m.put("role", b.getRole().name()); + m.put("alias", b.getAlias()); + m.put("status", b.getStatus().name()); + return m; + } + + // ==================== 工具方法 ==================== + + private AuthPrincipal requirePrincipal(HttpServletRequest request) { + AuthPrincipal principal = (AuthPrincipal) request.getAttribute(BearerAuthFilter.ATTR_PRINCIPAL); + if (principal == null) { + throw AuthException.unauthorized("令牌无效或缺失"); + } + return principal; + } + + private String extractBearer(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + String token = authorization.substring(7).trim(); + return token.isEmpty() ? null : token; + } + return null; + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/DeviceAuthController.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/DeviceAuthController.java new file mode 100644 index 0000000..d228437 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/DeviceAuthController.java @@ -0,0 +1,71 @@ +package com.ttstd.signaling.controller; + +import com.ttstd.signaling.controller.dto.AuthDtos; +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.security.RateLimiter; +import com.ttstd.signaling.service.DeviceIdentityService; +import com.ttstd.signaling.service.TokenPair; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 被控端设备认证接口。 + * + *

被控端无法使用账号登录,改由「SN + 内置共享密钥 HMAC」激活, + * 激活后凭 deviceSecret 换取短期访问令牌用于 WebSocket 握手。 + */ +@RestController +@RequestMapping("/api/device") +public class DeviceAuthController { + + private final DeviceIdentityService deviceIdentityService; + private final RateLimiter rateLimiter; + + public DeviceAuthController(DeviceIdentityService deviceIdentityService, RateLimiter rateLimiter) { + this.deviceIdentityService = deviceIdentityService; + this.rateLimiter = rateLimiter; + } + + /** + * 设备激活:返回 deviceUid 与 deviceSecret(deviceSecret 仅此一次明文返回)。 + */ + @PostMapping("/provision") + public ResponseEntity provision( + @Valid @RequestBody AuthDtos.ProvisionRequest request, + HttpServletRequest httpRequest) { + String ip = AuthController.clientIp(httpRequest); + if (!rateLimiter.tryAcquire("provision:" + ip, 10, 3600)) { + throw AuthException.tooManyRequests("激活请求过于频繁,请稍后再试"); + } + DeviceIdentityService.ProvisionResult result = deviceIdentityService.provision( + request.sn(), request.model(), request.nonce(), request.timestamp(), request.hmac()); + + return ResponseEntity.ok(new AuthDtos.ProvisionResponse( + result.deviceUid(), + result.deviceSecret(), + "deviceSecret 仅返回一次,请立即安全存储(建议 Android Keystore)")); + } + + /** + * 以 deviceUid + deviceSecret 换取短期设备访问令牌。 + */ + @PostMapping("/token") + public ResponseEntity token( + @Valid @RequestBody AuthDtos.DeviceTokenRequest request, + HttpServletRequest httpRequest) { + String ip = AuthController.clientIp(httpRequest); + if (!rateLimiter.tryAcquire("devtoken:" + ip, 60, 3600)) { + throw AuthException.tooManyRequests("请求过于频繁,请稍后再试"); + } + TokenPair pair = deviceIdentityService.issueDeviceToken( + request.deviceUid(), request.deviceSecret()); + return ResponseEntity.ok(new AuthDtos.TokenResponse( + pair.accessToken(), null, pair.expiresInSeconds(), + null, pair.principalId(), pair.displayName())); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/dto/AuthDtos.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/dto/AuthDtos.java new file mode 100644 index 0000000..79331b4 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/dto/AuthDtos.java @@ -0,0 +1,80 @@ +package com.ttstd.signaling.controller.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 认证相关请求/响应 DTO 集合。 + */ +public final class AuthDtos { + + private AuthDtos() { + } + + // ==================== 主控端账号 ==================== + + public record RegisterRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "密码不能为空") String password) { + } + + /** + * 登录请求。{@code totpCode} 仅在账号启用双因子时必填; + * 未填时服务端返回 error=TOTP_REQUIRED,客户端据此引导用户输入动态码。 + */ + public record LoginRequest( + @NotBlank(message = "用户名不能为空") String username, + @NotBlank(message = "密码不能为空") String password, + String totpCode) { + } + + /** 启用 TOTP:提交一次动态码完成绑定。 */ + public record TotpEnableRequest( + @NotBlank(message = "动态码不能为空") String code) { + } + + /** 关闭 TOTP:需同时提供密码与动态码。 */ + public record TotpDisableRequest( + @NotBlank(message = "密码不能为空") String password, + @NotBlank(message = "动态码不能为空") String code) { + } + + public record RefreshRequest( + @NotBlank(message = "刷新令牌不能为空") String refreshToken) { + } + + public record ChangePasswordRequest( + @NotBlank(message = "原密码不能为空") String oldPassword, + @NotBlank(message = "新密码不能为空") String newPassword) { + } + + /** 登录/刷新响应。设备令牌场景 refreshToken 为 null。 */ + public record TokenResponse( + String accessToken, + String refreshToken, + long expiresIn, + String sessionId, + String principalId, + String displayName) { + } + + // ==================== 被控端设备 ==================== + + public record ProvisionRequest( + @NotBlank(message = "SN 不能为空") String sn, + String model, + @NotBlank(message = "nonce 不能为空") String nonce, + long timestamp, + @NotBlank(message = "签名不能为空") String hmac) { + } + + public record ProvisionResponse( + String deviceUid, + String deviceSecret, + String notice) { + } + + public record DeviceTokenRequest( + @NotBlank(message = "deviceUid 不能为空") String deviceUid, + @NotBlank(message = "deviceSecret 不能为空") String deviceSecret) { + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java index f1f431c..50631b3 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java @@ -4,8 +4,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ttstd.signaling.manager.ConnectionRequestManager; import com.ttstd.signaling.manager.SessionManager; import com.ttstd.signaling.manager.SignalMetrics; +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.model.DeviceBinding; import com.ttstd.signaling.model.DeviceType; import com.ttstd.signaling.model.SignalMessage; +import com.ttstd.signaling.security.AuthHandshakeInterceptor; +import com.ttstd.signaling.service.AccountService; +import com.ttstd.signaling.service.AuditService; +import com.ttstd.signaling.service.BindingService; +import com.ttstd.signaling.service.DeviceIdentityService; +import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -18,6 +26,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; @Component public class SignalWebSocketHandler extends TextWebSocketHandler { @@ -25,40 +34,129 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { private static final Logger logger = LoggerFactory.getLogger(SignalWebSocketHandler.class); private static final ObjectMapper objectMapper = new ObjectMapper(); + /** 因鉴权/封禁被关闭连接时使用的关闭码 */ + private static final CloseStatus CLOSE_UNAUTHORIZED = new CloseStatus(4001, "UNAUTHORIZED"); + private static final CloseStatus CLOSE_FORCED_LOGOUT = new CloseStatus(4003, "FORCED_LOGOUT"); + private final SessionManager sessionManager; private final ConnectionRequestManager connectionRequestManager; private final SignalMetrics metrics; + private final AccountService accountService; + private final DeviceIdentityService deviceIdentityService; + private final BindingService bindingService; + private final AuditService auditService; public SignalWebSocketHandler(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager, - SignalMetrics metrics) { + SignalMetrics metrics, + AccountService accountService, + DeviceIdentityService deviceIdentityService, + BindingService bindingService, + AuditService auditService) { this.sessionManager = sessionManager; this.connectionRequestManager = connectionRequestManager; this.metrics = metrics; + this.accountService = accountService; + this.deviceIdentityService = deviceIdentityService; + this.bindingService = bindingService; + this.auditService = auditService; this.connectionRequestManager.setSender(this::sendToDevice); } + /** + * 注册会话失效回调:账号被封禁 / 会话被踢出 / 设备被禁用时立即断开对应连接。 + */ + @PostConstruct + void registerRevocationListener() { + AccountService.SessionRevocationListener listener = this::forceDisconnect; + accountService.setRevocationListener(listener); + deviceIdentityService.setRevocationListener(listener); + } + + /** + * 强制断开指定主体的连接。 + * + * @param principalId 主体 ID + * @param sessionId 指定登录会话;为 null 表示断开该主体全部连接 + */ + private void forceDisconnect(String principalId, String sessionId, String reason) { + Set deviceIds = sessionManager.getDeviceIdsByPrincipal(principalId); + for (String deviceId : deviceIds) { + AuthPrincipal principal = sessionManager.getPrincipal(deviceId); + if (principal == null) { + continue; + } + // 指定了会话时只断开该会话,避免误伤同账号其他端 + if (sessionId != null && !sessionId.equals(principal.sessionId())) { + continue; + } + WebSocketSession session = sessionManager.getSession(deviceId); + if (session == null || !session.isOpen()) { + continue; + } + Map notice = new HashMap<>(); + notice.put("type", "FORCE_LOGOUT"); + notice.put("payload", reason == null ? "会话已失效" : reason); + sendToSession(session, notice); + try { + session.close(CLOSE_FORCED_LOGOUT); + } catch (IOException e) { + logger.warn("强制断开连接 {} 失败: {}", deviceId, e.getMessage()); + } + logger.info("已强制断开连接: deviceId={} principal={} reason={}", + deviceId, principalId, reason); + } + } + /** * 向指定设备回送服务端事件(错误/超时通知等)。 */ private void sendToDevice(String deviceId, Object message) { WebSocketSession session = sessionManager.getSession(deviceId); if (session == null || !session.isOpen()) { - logger.warn("Cannot send event to {}: device offline", deviceId); + logger.warn("无法向 {} 发送事件:连接不在线", deviceId); return; } sendToSession(session, message); } + /** + * 连接建立后立即以握手阶段裁定的身份完成注册,无需客户端再发 REGISTER。 + */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { - logger.info("New WebSocket connection: {}", session.getId()); + AuthPrincipal principal = principalOf(session); + if (principal == null) { + // 正常情况下握手拦截器已拦截,此处为纵深防御 + logger.warn("连接 {} 缺少鉴权主体,立即关闭", session.getId()); + session.close(CLOSE_UNAUTHORIZED); + return; + } + + sessionManager.registerDevice(principal, session); + metrics.incRegistered(); + metrics.recordSessionCount(sessionManager.getOnlineCount()); + + Map response = new HashMap<>(); + response.put("type", "REGISTER_SUCCESS"); + response.put("deviceId", principal.deviceId()); + response.put("deviceType", principal.deviceType().name()); + response.put("displayName", principal.displayName()); + sendToSession(session, response); + + logger.info("连接已建立并注册: {} ({})", principal.deviceId(), principal.deviceType()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + AuthPrincipal principal = principalOf(session); + if (principal == null) { + session.close(CLOSE_UNAUTHORIZED); + return; + } + String payload = message.getPayload(); - logger.debug("Received message: {}", payload); + logger.debug("收到消息: {}", payload); metrics.incMessage(); try { @@ -66,22 +164,54 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { String type = signalMessage.getType(); if (type == null) { - logger.warn("Message type is null"); + logger.warn("消息缺少 type 字段"); return; } + // 关键:发送方身份一律以服务端鉴权结果覆盖,忽略客户端自报值, + // 防止伪造 fromDeviceId 冒充他人。 + String claimedFrom = signalMessage.getFromDeviceId(); + if (claimedFrom != null && !claimedFrom.equals(principal.deviceId())) { + logger.warn("客户端自报 fromDeviceId={} 与鉴权身份 {} 不一致,已强制覆盖", + claimedFrom, principal.deviceId()); + } + signalMessage.setFromDeviceId(principal.deviceId()); + signalMessage.setDeviceType(principal.deviceType().name()); + switch (type.toUpperCase()) { case "REGISTER": - handleRegister(session, signalMessage); + // 身份已在握手阶段确定,REGISTER 仅作兼容响应 + handleLegacyRegister(session, principal); break; case "DEVICE_LIST": - handleDeviceList(session, signalMessage); + handleDeviceList(session, principal); break; case "PING": // 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接 break; case "OFFER": metrics.incOffer(); + // 绑定/黑名单前置校验:仅允许已绑定且未被拉黑的主控端发起 + if (principal.deviceType() == DeviceType.CONTROLLER) { + String targetUid = signalMessage.getToDeviceId(); + if (targetUid == null || targetUid.isEmpty()) { + sendError(session, "缺少目标设备ID"); + metrics.incBlockedOffer(); + break; + } + if (!bindingService.isBound(targetUid, principal.principalId())) { + sendError(session, "未与该设备建立绑定关系,无法发起连接"); + metrics.incBlockedOffer(); + auditOfferBlocked(targetUid, principal.principalId(), "NOT_BOUND"); + break; + } + if (bindingService.isBlacklisted(targetUid, principal.principalId())) { + sendError(session, "该设备已拒绝来自你的连接"); + metrics.incBlockedOffer(); + auditOfferBlocked(targetUid, principal.principalId(), "BLACKLISTED"); + break; + } + } // 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发 handleConnectionRequest(signalMessage); break; @@ -110,13 +240,13 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { break; } } catch (Exception e) { - logger.error("Error handling message: {}", e.getMessage(), e); + logger.error("处理消息出错: {}", e.getMessage(), e); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { - logger.info("WebSocket connection closed: {} ({})", session.getId(), status); + logger.info("连接已关闭: {} ({})", session.getId(), status); sessionManager.unregisterSession(session); metrics.recordSessionCount(sessionManager.getOnlineCount()); } @@ -131,10 +261,10 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { || exception.getMessage().contains("Broken pipe") || exception.getMessage().contains("An established connection"))); if (benign) { - logger.debug("Transport closed (client disconnected) on session {}: {}", + logger.debug("传输层关闭(客户端断开),会话 {}: {}", session.getId(), exception.getMessage()); } else { - logger.warn("Transport error on session {}: {}", session.getId(), exception.getMessage()); + logger.warn("传输层错误,会话 {}: {}", session.getId(), exception.getMessage()); } sessionManager.unregisterSession(session); metrics.recordSessionCount(sessionManager.getOnlineCount()); @@ -144,44 +274,64 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { } } - private void handleRegister(WebSocketSession session, SignalMessage message) { - String deviceId = message.getFromDeviceId(); - String deviceTypeStr = message.getDeviceType(); - - if (deviceId == null || deviceTypeStr == null) { - logger.warn("Invalid REGISTER message: missing deviceId or deviceType"); - return; - } - - try { - DeviceType deviceType = DeviceType.valueOf(deviceTypeStr.toUpperCase()); - sessionManager.registerDevice(deviceId, deviceType, session); - metrics.incRegistered(); - metrics.recordSessionCount(sessionManager.getOnlineCount()); - - // 回复注册成功 - Map response = new HashMap<>(); - response.put("type", "REGISTER_SUCCESS"); - response.put("deviceId", deviceId); - sendToSession(session, response); - - logger.info("Device {} registered as {}", deviceId, deviceType); - } catch (IllegalArgumentException e) { - logger.warn("Invalid device type: {}", deviceTypeStr); - } + private AuthPrincipal principalOf(WebSocketSession session) { + Object attr = session.getAttributes().get(AuthHandshakeInterceptor.ATTR_PRINCIPAL); + return attr instanceof AuthPrincipal principal ? principal : null; } - private void handleDeviceList(WebSocketSession session, SignalMessage message) { - List controllers = sessionManager.getDevicesByType(DeviceType.CONTROLLER); - List controlled = sessionManager.getDevicesByType(DeviceType.CONTROLLED); + /** + * 兼容旧客户端的 REGISTER:不再接受客户端自报身份,仅回显服务端裁定结果。 + */ + private void handleLegacyRegister(WebSocketSession session, AuthPrincipal principal) { + Map response = new HashMap<>(); + response.put("type", "REGISTER_SUCCESS"); + response.put("deviceId", principal.deviceId()); + response.put("deviceType", principal.deviceType().name()); + response.put("displayName", principal.displayName()); + sendToSession(session, response); + } + /** + * 设备列表(绑定视图)。 + * + *

为防止枚举被控端,服务端不再向任意主控端返回全局在线设备清单。 + * 主控端仅能看到「自己已绑定」的设备及其在线状态;被控端此项为空列表。 + */ + private void handleDeviceList(WebSocketSession session, AuthPrincipal principal) { Map response = new HashMap<>(); response.put("type", "DEVICE_LIST"); - response.put("controllers", controllers); - response.put("controlled", controlled); + + if (principal.deviceType() == DeviceType.CONTROLLER) { + List> devices = bindingService.listByUser(principal.principalId()) + .stream() + .filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE) + .map(b -> { + Map item = new HashMap<>(); + item.put("deviceUid", b.getDeviceUid()); + item.put("alias", b.getAlias()); + item.put("role", b.getRole().name()); + item.put("online", sessionManager.isDeviceOnline(b.getDeviceUid())); + return item; + }) + .toList(); + response.put("boundDevices", devices); + } else { + // 被控端无需浏览主控端列表 + response.put("boundDevices", List.of()); + } sendToSession(session, response); } + /** 记录被拦截的 OFFER(未绑定/被拉黑),便于审计与风控。 */ + private void auditOfferBlocked(String deviceUid, String userId, String reason) { + try { + auditService.recordUser(userId, AuditService.ACTION_OFFER_BLOCKED, + AuditService.RESULT_SUCCESS, null, "device=" + deviceUid + " reason=" + reason); + } catch (Exception e) { + logger.warn("审计 OFFER 拦截记录失败: {}", e.getMessage()); + } + } + /** * 处理主控端发来的连接请求(OFFER):先做设备类型校验与去重, * 再登记“待被控端确认”状态,最后转发给被控端。 @@ -193,7 +343,7 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { // 1. 校验:仅允许 CONTROLLER -> CONTROLLED String error = connectionRequestManager.validateOffer(fromDeviceId, toDeviceId); if (error != null) { - logger.warn("Invalid connection request {} -> {}: {}", fromDeviceId, toDeviceId, error); + logger.warn("非法连接请求 {} -> {}: {}", fromDeviceId, toDeviceId, error); Map response = new HashMap<>(); response.put("type", "REQUEST_ERROR"); response.put("toDeviceId", toDeviceId); @@ -225,13 +375,13 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { // 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗 if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) { - logger.info("Duplicate connection request {} -> {} ignored", fromDeviceId, toDeviceId); + logger.info("重复连接请求 {} -> {} 已忽略", fromDeviceId, toDeviceId); return; } // 3. 目标不在线:立即回送 TARGET_OFFLINE,且不登记待确认(避免等待超时) if (!sessionManager.isDeviceOnline(toDeviceId)) { - logger.warn("Target device {} is offline, cannot deliver connection request", toDeviceId); + logger.warn("目标被控端 {} 不在线,无法投递连接请求", toDeviceId); notifySenderTargetOffline(message, toDeviceId); return; } @@ -244,13 +394,13 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { private void forwardMessage(SignalMessage message, boolean notifyOffline) { String toDeviceId = message.getToDeviceId(); if (toDeviceId == null) { - logger.warn("Cannot forward message: toDeviceId is null"); + logger.warn("无法转发消息:toDeviceId 为空"); return; } WebSocketSession targetSession = sessionManager.getSession(toDeviceId); if (targetSession == null || !targetSession.isOpen()) { - logger.warn("Target device {} is not online", toDeviceId); + logger.warn("目标设备 {} 不在线", toDeviceId); // 仅 OFFER / ANSWER 在目标不在线时回送 TARGET_OFFLINE,其余类型静默丢弃 if (notifyOffline) { notifySenderTargetOffline(message, toDeviceId); @@ -261,9 +411,9 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { try { String jsonMessage = objectMapper.writeValueAsString(message); targetSession.sendMessage(new TextMessage(jsonMessage)); - logger.debug("Forwarded {} from {} to {}", message.getType(), message.getFromDeviceId(), toDeviceId); + logger.debug("已转发 {}:{} -> {}", message.getType(), message.getFromDeviceId(), toDeviceId); } catch (IOException e) { - logger.error("Error forwarding message to {}: {}", toDeviceId, e.getMessage()); + logger.error("转发消息到 {} 失败: {}", toDeviceId, e.getMessage()); } } @@ -273,23 +423,23 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { private void notifySenderTargetOffline(SignalMessage message, String offlineDeviceId) { String fromDeviceId = message.getFromDeviceId(); if (fromDeviceId == null) { - logger.warn("Cannot notify offline state: fromDeviceId is null"); + logger.warn("无法通知离线状态:fromDeviceId 为空"); return; } WebSocketSession senderSession = sessionManager.getSession(fromDeviceId); if (senderSession == null || !senderSession.isOpen()) { - logger.warn("Sender {} session not found, cannot notify target offline", fromDeviceId); + logger.warn("发送方 {} 会话不存在,无法通知目标离线", fromDeviceId); return; } Map response = new HashMap<>(); response.put("type", "TARGET_OFFLINE"); response.put("toDeviceId", offlineDeviceId); - response.put("payload", "目标被控端(" + offlineDeviceId + ")不在线,请确认设备已开启并连接到信令服务器"); + response.put("payload", "目标被控端不在线,请确认设备已开启并连接到信令服务器"); sendToSession(senderSession, response); - logger.info("Notified sender {} that target {} is offline", fromDeviceId, offlineDeviceId); + logger.info("已通知发送方 {} 目标 {} 不在线", fromDeviceId, offlineDeviceId); } private void sendToSession(WebSocketSession session, Object data) { @@ -297,7 +447,15 @@ public class SignalWebSocketHandler extends TextWebSocketHandler { String json = objectMapper.writeValueAsString(data); session.sendMessage(new TextMessage(json)); } catch (IOException e) { - logger.error("Error sending message to session {}: {}", session.getId(), e.getMessage()); + logger.error("向会话 {} 发送消息失败: {}", session.getId(), e.getMessage()); } } + + /** 向发送方回送一条 REQUEST_ERROR 业务提示(不关闭连接)。 */ + private void sendError(WebSocketSession session, String message) { + Map response = new HashMap<>(); + response.put("type", "REQUEST_ERROR"); + response.put("payload", message); + sendToSession(session, response); + } } diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java index dd417ec..71693f3 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SessionManager.java @@ -1,5 +1,6 @@ package com.ttstd.signaling.manager; +import com.ttstd.signaling.model.AuthPrincipal; import com.ttstd.signaling.model.DeviceInfo; import com.ttstd.signaling.model.DeviceType; import org.slf4j.Logger; @@ -14,6 +15,12 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +/** + * 在线会话管理。 + * + *

所有注册信息均来源于握手阶段已鉴权的 {@link AuthPrincipal}, + * 客户端无法通过消息自行声明身份。 + */ @Component public class SessionManager { @@ -27,13 +34,26 @@ public class SessionManager { private final Map sessionToDevice = new ConcurrentHashMap<>(); // deviceId -> 注册(上线)时间戳 private final Map connectTime = new ConcurrentHashMap<>(); + // deviceId -> 已鉴权主体 + private final Map principals = new ConcurrentHashMap<>(); + // principalId -> 该主体当前占用的 deviceId 集合(一个账号可能多端登录) + private final Map> principalDevices = new ConcurrentHashMap<>(); - public void registerDevice(String deviceId, DeviceType deviceType, WebSocketSession session) { + /** + * 注册已鉴权的连接。deviceId 与 deviceType 均取自 {@link AuthPrincipal}。 + */ + public void registerDevice(AuthPrincipal principal, WebSocketSession session) { + String deviceId = principal.deviceId(); sessions.put(deviceId, session); - deviceTypes.put(deviceId, deviceType); + deviceTypes.put(deviceId, principal.deviceType()); sessionToDevice.put(session.getId(), deviceId); connectTime.put(deviceId, System.currentTimeMillis()); - logger.info("Device registered: {} ({})", deviceId, deviceType); + principals.put(deviceId, principal); + principalDevices + .computeIfAbsent(principal.principalId(), k -> ConcurrentHashMap.newKeySet()) + .add(deviceId); + logger.info("连接已注册: deviceId={} type={} principal={}", + deviceId, principal.deviceType(), principal.principalId()); } public void unregisterSession(WebSocketSession session) { @@ -42,7 +62,17 @@ public class SessionManager { sessions.remove(deviceId); deviceTypes.remove(deviceId); connectTime.remove(deviceId); - logger.info("Device unregistered: {}", deviceId); + AuthPrincipal principal = principals.remove(deviceId); + if (principal != null) { + Set owned = principalDevices.get(principal.principalId()); + if (owned != null) { + owned.remove(deviceId); + if (owned.isEmpty()) { + principalDevices.remove(principal.principalId()); + } + } + } + logger.info("连接已注销: {}", deviceId); } } @@ -50,6 +80,16 @@ public class SessionManager { return sessions.get(deviceId); } + public AuthPrincipal getPrincipal(String deviceId) { + return principals.get(deviceId); + } + + /** 返回指定主体当前所有在线连接的 deviceId。 */ + public Set getDeviceIdsByPrincipal(String principalId) { + Set owned = principalDevices.get(principalId); + return owned == null ? Set.of() : Set.copyOf(owned); + } + public boolean isDeviceOnline(String deviceId) { WebSocketSession session = sessions.get(deviceId); return session != null && session.isOpen(); diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SignalMetrics.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SignalMetrics.java index 5160723..6f6c2e2 100644 --- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SignalMetrics.java +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/manager/SignalMetrics.java @@ -13,6 +13,7 @@ public class SignalMetrics { private final AtomicLong totalOffers = new AtomicLong(0); private final AtomicLong totalAnswers = new AtomicLong(0); private final AtomicLong totalRejected = new AtomicLong(0); + private final AtomicLong totalBlockedOffers = new AtomicLong(0); private final AtomicLong totalRegistered = new AtomicLong(0); private final AtomicLong peakSessions = new AtomicLong(0); public void incMessage() { @@ -27,6 +28,9 @@ public class SignalMetrics { public void incRejected() { totalRejected.incrementAndGet(); } + public void incBlockedOffer() { + totalBlockedOffers.incrementAndGet(); + } public void incRegistered() { totalRegistered.incrementAndGet(); } @@ -45,6 +49,7 @@ public class SignalMetrics { map.put("totalOffers", totalOffers.get()); map.put("totalAnswers", totalAnswers.get()); map.put("totalRejected", totalRejected.get()); + map.put("totalBlockedOffers", totalBlockedOffers.get()); map.put("totalRegistered", totalRegistered.get()); map.put("peakSessions", peakSessions.get()); return map; diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AbuseReport.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AbuseReport.java new file mode 100644 index 0000000..005d55b --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AbuseReport.java @@ -0,0 +1,62 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 骚扰举报:被控端对某个主控端账号发起的举报记录(P2 风控)。 + */ +@Entity +@Table(name = "abuse_report") +public class AbuseReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "device_uid", length = 48, nullable = false) + private String deviceUid; + + @Column(name = "reported_user_id", length = 48, nullable = false) + private String reportedUserId; + + @Column(name = "reason", length = 255) + private String reason; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 16, nullable = false) + private ReportStatus status = ReportStatus.PENDING; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + protected AbuseReport() { + } + + public AbuseReport(String deviceUid, String reportedUserId, String reason) { + this.deviceUid = deviceUid; + this.reportedUserId = reportedUserId; + this.reason = reason; + this.status = ReportStatus.PENDING; + this.createdAt = Instant.now(); + } + + public Long getId() { return id; } + public String getDeviceUid() { return deviceUid; } + public String getReportedUserId() { return reportedUserId; } + public String getReason() { return reason; } + public ReportStatus getStatus() { return status; } + public void setStatus(ReportStatus status) { this.status = status; } + public Instant getCreatedAt() { return createdAt; } + + public enum ReportStatus { PENDING, HANDLED, DISMISSED } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AccountStatus.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AccountStatus.java new file mode 100644 index 0000000..fd16201 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AccountStatus.java @@ -0,0 +1,13 @@ +package com.ttstd.signaling.model; + +/** + * 账号 / 设备状态。 + */ +public enum AccountStatus { + /** 正常 */ + ACTIVE, + /** 临时封禁(到期自动恢复) */ + SUSPENDED, + /** 永久封禁 */ + BANNED +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AuthPrincipal.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AuthPrincipal.java new file mode 100644 index 0000000..81d80fc --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/AuthPrincipal.java @@ -0,0 +1,30 @@ +package com.ttstd.signaling.model; + +/** + * 已鉴权主体:由服务端在握手/请求鉴权阶段裁定,客户端无法自行声明。 + * + * @param principalType 主体类型(用户 / 设备) + * @param principalId 主体 ID(userId 或 deviceUid) + * @param deviceId 在信令网络中的寻址 ID(服务端下发,不接受客户端自报) + * @param deviceType 信令角色(CONTROLLER / CONTROLLED) + * @param sessionId 登录会话 ID,用于精准踢线 + * @param displayName 展示名(用户名 / 设备型号),仅用于日志与管理后台 + * @param admin 是否为管理员账号 + */ +public record AuthPrincipal( + PrincipalType principalType, + String principalId, + String deviceId, + DeviceType deviceType, + String sessionId, + String displayName, + boolean admin) { + + public boolean isUser() { + return principalType == PrincipalType.USER; + } + + public boolean isDevice() { + return principalType == PrincipalType.DEVICE; + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceAccount.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceAccount.java new file mode 100644 index 0000000..245ba81 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceAccount.java @@ -0,0 +1,120 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 被控端设备身份(同时作为 JPA 实体持久化到 MySQL)。 + * + *

SN 由系统签名应用获取,仅作为服务端内部主键使用;对外一律使用高熵、不可枚举的 + * {@code deviceUid},避免主控端通过猜测 SN 定位并骚扰被控端。 + */ +@Entity +@Table(name = "device_account") +public class DeviceAccount { + + @Id + @Column(name = "device_uid", length = 48, nullable = false) + private String deviceUid; + + @Column(name = "sn", length = 64, unique = true, nullable = false) + private String sn; + + @Column(name = "secret_hash", length = 100, nullable = false) + private String secretHash; + + @Column(name = "model", length = 64) + private String model; + + @Column(name = "provisioned_at", nullable = false) + private Instant provisionedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 16, nullable = false) + private AccountStatus status = AccountStatus.ACTIVE; + + /** 封禁到期时间(仅 SUSPENDED 有效),为空表示不自动恢复 */ + @Column(name = "status_until") + private Instant statusUntil; + + @Column(name = "status_reason", length = 255) + private String statusReason; + + @Column(name = "token_version", nullable = false) + private long tokenVersion = 1L; + + @Column(name = "last_online_at") + private Instant lastOnlineAt; + + protected DeviceAccount() { + this.deviceUid = null; + this.sn = null; + this.secretHash = null; + this.model = null; + this.provisionedAt = Instant.now(); + } + + public DeviceAccount(String deviceUid, String sn, String secretHash, String model) { + this.deviceUid = deviceUid; + this.sn = sn; + this.secretHash = secretHash; + this.model = model; + this.provisionedAt = Instant.now(); + } + + public String getDeviceUid() { return deviceUid; } + public String getSn() { return sn; } + public Instant getProvisionedAt() { return provisionedAt; } + + public String getSecretHash() { return secretHash; } + public void setSecretHash(String secretHash) { this.secretHash = secretHash; } + + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + + public AccountStatus getStatus() { return status; } + public void setStatus(AccountStatus status) { this.status = status; } + + public Instant getStatusUntil() { return statusUntil; } + public void setStatusUntil(Instant statusUntil) { this.statusUntil = statusUntil; } + + public String getStatusReason() { return statusReason; } + public void setStatusReason(String statusReason) { this.statusReason = statusReason; } + + public long getTokenVersion() { return tokenVersion; } + public void setTokenVersion(long tokenVersion) { this.tokenVersion = tokenVersion; } + + public Instant getLastOnlineAt() { return lastOnlineAt; } + public void setLastOnlineAt(Instant lastOnlineAt) { this.lastOnlineAt = lastOnlineAt; } + + public boolean isUsable() { + if (status == AccountStatus.BANNED) { + return false; + } + if (status == AccountStatus.SUSPENDED) { + return statusUntil != null && Instant.now().isAfter(statusUntil); + } + return true; + } + + /** 日志脱敏:SN 仅展示后 4 位。 */ + public String maskedSn() { + return maskSn(sn); + } + + public static String maskSn(String sn) { + if (sn == null || sn.isEmpty()) { + return "unknown"; + } + if (sn.length() <= 4) { + return "****"; + } + return "****" + sn.substring(sn.length() - 4); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceAllowlist.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceAllowlist.java new file mode 100644 index 0000000..4056996 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceAllowlist.java @@ -0,0 +1,50 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 被控端 SN 白名单:仅白名单内的 SN 允许激活(provision)。 + * 由管理后台批量导入,作为 JPA 实体持久化到 MySQL。 + */ +@Entity +@Table(name = "device_allowlist") +public class DeviceAllowlist { + + @Id + @Column(name = "sn", length = 64, nullable = false) + private String sn; + + @Column(name = "batch", length = 64) + private String batch; + + @Column(name = "imported_by", length = 64) + private String importedBy; + + @Column(name = "imported_at", nullable = false) + private Instant importedAt; + + protected DeviceAllowlist() { + this.sn = null; + this.importedAt = Instant.now(); + } + + public DeviceAllowlist(String sn, String batch, String importedBy) { + this.sn = sn; + this.batch = batch; + this.importedBy = importedBy; + this.importedAt = Instant.now(); + } + + public String getSn() { return sn; } + public String getBatch() { return batch; } + public void setBatch(String batch) { this.batch = batch; } + public String getImportedBy() { return importedBy; } + public void setImportedBy(String importedBy) { this.importedBy = importedBy; } + public Instant getImportedAt() { return importedAt; } + public void setImportedAt(Instant importedAt) { this.importedAt = importedAt; } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceBinding.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceBinding.java new file mode 100644 index 0000000..a3b548b --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceBinding.java @@ -0,0 +1,101 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 绑定关系:主控端账号与受控端设备之间的授权连接。 + * + *

只有存在 {@code ACTIVE} 绑定的主控端,才被允许向该设备发起信令(OFFER)。 + * 绑定关系由被控端(设备令牌)或管理员创建,可从 {@code REVOKED} 恢复为 {@code ACTIVE}。 + */ +@Entity +@Table(name = "device_binding") +public class DeviceBinding { + + /** 绑定标识(对外 ID,高熵不可枚举) */ + @Id + @Column(name = "binding_id", length = 48, nullable = false) + private String bindingId; + + /** 被控端设备 UID */ + @Column(name = "device_uid", length = 48, nullable = false) + private String deviceUid; + + /** 主控端用户 ID(对应 user_account.user_id) */ + @Column(name = "user_id", length = 48, nullable = false) + private String userId; + + /** 绑定角色:OWNER(可管理其他绑定)/ MEMBER(仅连接) */ + @Enumerated(EnumType.STRING) + @Column(name = "role", length = 16, nullable = false) + private BindingRole role = BindingRole.MEMBER; + + /** 主控端为设备设置的别名 */ + @Column(name = "alias", length = 64) + private String alias; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 16, nullable = false) + private BindingStatus status = BindingStatus.ACTIVE; + + /** 创建者(设备 UID 或 admin 标识) */ + @Column(name = "bound_by", length = 64) + private String boundBy; + + @Column(name = "bound_at", nullable = false) + private Instant boundAt = Instant.now(); + + @Column(name = "expire_at") + private Instant expireAt; + + protected DeviceBinding() { + } + + public DeviceBinding(String bindingId, String deviceUid, String userId, + BindingRole role, String alias, String boundBy) { + this.bindingId = bindingId; + this.deviceUid = deviceUid; + this.userId = userId; + this.role = role; + this.alias = alias; + this.boundBy = boundBy; + this.boundAt = Instant.now(); + } + + public String getBindingId() { return bindingId; } + public String getDeviceUid() { return deviceUid; } + public String getUserId() { return userId; } + + public BindingRole getRole() { return role; } + public void setRole(BindingRole role) { this.role = role; } + + public String getAlias() { return alias; } + public void setAlias(String alias) { this.alias = alias; } + + public BindingStatus getStatus() { return status; } + public void setStatus(BindingStatus status) { this.status = status; } + + public String getBoundBy() { return boundBy; } + public void setBoundBy(String boundBy) { this.boundBy = boundBy; } + + public Instant getBoundAt() { return boundAt; } + public Instant getExpireAt() { return expireAt; } + public void setExpireAt(Instant expireAt) { this.expireAt = expireAt; } + + public boolean isActive() { + if (status != BindingStatus.ACTIVE) { + return false; + } + return expireAt == null || Instant.now().isBefore(expireAt); + } + + public enum BindingRole { OWNER, MEMBER } + public enum BindingStatus { ACTIVE, REVOKED } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceBlacklistEntry.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceBlacklistEntry.java new file mode 100644 index 0000000..00a3828 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/DeviceBlacklistEntry.java @@ -0,0 +1,85 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.time.Instant; + +/** + * 被控端黑名单:被控端拉黑的主控端账号。 + * + *

当被拉黑的主控端向该设备发起 OFFER 时,服务端在转发前直接拒绝, + * 即便双方存在绑定关系也不予放行(黑名单优先级高于绑定)。 + */ +@Entity +@Table(name = "device_blacklist") +@IdClass(DeviceBlacklistEntry.PK.class) +public class DeviceBlacklistEntry { + + @Id + @Column(name = "device_uid", length = 48, nullable = false) + private String deviceUid; + + @Id + @Column(name = "blocked_user_id", length = 48, nullable = false) + private String blockedUserId; + + @Column(name = "reason", length = 255) + private String reason; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + protected DeviceBlacklistEntry() { + } + + public DeviceBlacklistEntry(String deviceUid, String blockedUserId, String reason) { + this.deviceUid = deviceUid; + this.blockedUserId = blockedUserId; + this.reason = reason; + this.createdAt = Instant.now(); + } + + public String getDeviceUid() { return deviceUid; } + public String getBlockedUserId() { return blockedUserId; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public Instant getCreatedAt() { return createdAt; } + + /** 复合主键 */ + public static class PK implements Serializable { + private String deviceUid; + private String blockedUserId; + + public PK() { + } + + public PK(String deviceUid, String blockedUserId) { + this.deviceUid = deviceUid; + this.blockedUserId = blockedUserId; + } + + public String getDeviceUid() { return deviceUid; } + public void setDeviceUid(String deviceUid) { this.deviceUid = deviceUid; } + public String getBlockedUserId() { return blockedUserId; } + public void setBlockedUserId(String blockedUserId) { this.blockedUserId = blockedUserId; } + + @Override + public boolean equals(Object o) { + if (!(o instanceof PK pk)) { + return false; + } + return java.util.Objects.equals(deviceUid, pk.deviceUid) + && java.util.Objects.equals(blockedUserId, pk.blockedUserId); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(deviceUid, blockedUserId); + } + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/LoginSession.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/LoginSession.java new file mode 100644 index 0000000..c15d9da --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/LoginSession.java @@ -0,0 +1,105 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 登录会话:记录一次登录产生的刷新令牌上下文,支持会话查看、单独踢出与刷新令牌轮转。 + * 同时作为 JPA 实体持久化到 MySQL。 + */ +@Entity +@Table(name = "login_session") +public class LoginSession { + + @Id + @Column(name = "session_id", length = 48, nullable = false) + private String sessionId; + + @Column(name = "principal_id", length = 64, nullable = false) + private String principalId; + + @Enumerated(EnumType.STRING) + @Column(name = "principal_type", length = 16, nullable = false) + private PrincipalType principalType; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "ip", length = 64) + private String ip; + + @Column(name = "user_agent", length = 256) + private String userAgent; + + /** 当前有效刷新令牌的哈希;轮转后旧值失效 */ + @Column(name = "refresh_token_hash", length = 100, nullable = false) + private String refreshTokenHash; + + @Column(name = "refresh_expires_at", nullable = false) + private Instant refreshExpiresAt; + + @Column(name = "last_seen_at", nullable = false) + private Instant lastSeenAt; + + @Column(name = "revoked", nullable = false) + private boolean revoked; + + protected LoginSession() { + this.sessionId = null; + this.principalId = null; + this.principalType = null; + this.refreshTokenHash = null; + this.refreshExpiresAt = Instant.now(); + this.ip = null; + this.userAgent = null; + this.createdAt = Instant.now(); + this.lastSeenAt = this.createdAt; + } + + public LoginSession(String sessionId, + String principalId, + PrincipalType principalType, + String refreshTokenHash, + Instant refreshExpiresAt, + String ip, + String userAgent) { + this.sessionId = sessionId; + this.principalId = principalId; + this.principalType = principalType; + this.refreshTokenHash = refreshTokenHash; + this.refreshExpiresAt = refreshExpiresAt; + this.ip = ip; + this.userAgent = userAgent; + this.createdAt = Instant.now(); + this.lastSeenAt = this.createdAt; + } + + public String getSessionId() { return sessionId; } + public String getPrincipalId() { return principalId; } + public PrincipalType getPrincipalType() { return principalType; } + public Instant getCreatedAt() { return createdAt; } + public String getIp() { return ip; } + public String getUserAgent() { return userAgent; } + + public String getRefreshTokenHash() { return refreshTokenHash; } + public void setRefreshTokenHash(String refreshTokenHash) { this.refreshTokenHash = refreshTokenHash; } + + public Instant getRefreshExpiresAt() { return refreshExpiresAt; } + public void setRefreshExpiresAt(Instant refreshExpiresAt) { this.refreshExpiresAt = refreshExpiresAt; } + + public Instant getLastSeenAt() { return lastSeenAt; } + public void setLastSeenAt(Instant lastSeenAt) { this.lastSeenAt = lastSeenAt; } + + public boolean isRevoked() { return revoked; } + public void setRevoked(boolean revoked) { this.revoked = revoked; } + + public boolean isActive() { + return !revoked && refreshExpiresAt != null && Instant.now().isBefore(refreshExpiresAt); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/PairingCode.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/PairingCode.java new file mode 100644 index 0000000..e37da4d --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/PairingCode.java @@ -0,0 +1,61 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 一次性配对码:用于被控端向主控端「授权绑定」。 + * + *

流程:被控端生成配对码(明文仅回显一次)→ 主控端输入码 → 服务端校验并创建绑定。 + * 配对码存其 SHA-256 摘要,明文不落库;设有有效期、使用次数与错误尝试上限,防爆破。 + */ +@Entity +@Table(name = "pairing_code") +public class PairingCode { + + @Id + @Column(name = "code_hash", length = 64, nullable = false) + private String codeHash; + + @Column(name = "device_uid", length = 48, nullable = false) + private String deviceUid; + + @Column(name = "attempts", nullable = false) + private int attempts = 0; + + @Column(name = "used", nullable = false) + private boolean used = false; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + protected PairingCode() { + } + + public PairingCode(String codeHash, String deviceUid, Instant expiresAt) { + this.codeHash = codeHash; + this.deviceUid = deviceUid; + this.expiresAt = expiresAt; + this.createdAt = Instant.now(); + } + + public String getCodeHash() { return codeHash; } + public String getDeviceUid() { return deviceUid; } + public int getAttempts() { return attempts; } + public void setAttempts(int attempts) { this.attempts = attempts; } + public boolean isUsed() { return used; } + public void setUsed(boolean used) { this.used = used; } + public Instant getExpiresAt() { return expiresAt; } + public Instant getCreatedAt() { return createdAt; } + + public boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/PrincipalType.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/PrincipalType.java new file mode 100644 index 0000000..f953d97 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/PrincipalType.java @@ -0,0 +1,11 @@ +package com.ttstd.signaling.model; + +/** + * 连接主体类型:区分“用户账号”(主控端)与“设备身份”(被控端)。 + */ +public enum PrincipalType { + /** 主控端:由用户账号登录后取得身份 */ + USER, + /** 被控端:由设备激活后取得身份(系统签名应用,可获取固定 SN) */ + DEVICE +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/UserAccount.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/UserAccount.java new file mode 100644 index 0000000..075f3a8 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/model/UserAccount.java @@ -0,0 +1,198 @@ +package com.ttstd.signaling.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 主控端用户账号(同时作为 JPA 实体持久化到 MySQL)。 + */ +@Entity +@Table(name = "app_user") +public class UserAccount { + + /** 密码历史保留条数,用于禁止复用近期密码 */ + public static final int PASSWORD_HISTORY_SIZE = 5; + + @Id + @Column(name = "user_id", length = 64, nullable = false) + private String userId; + + @Column(name = "username", length = 64, unique = true, nullable = false) + private String username; + + @Column(name = "password_hash", length = 100, nullable = false) + private String passwordHash; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 16, nullable = false) + private AccountStatus status = AccountStatus.ACTIVE; + + /** 封禁到期时间(仅 SUSPENDED 有效),为空表示不自动恢复 */ + @Column(name = "status_until") + private Instant statusUntil; + + @Column(name = "status_reason", length = 255) + private String statusReason; + + /** + * 凭据版本号:递增后所有已签发的 token 立即失效。 + * 用于封禁、改密、全端下线等场景。 + */ + @Column(name = "token_version", nullable = false) + private long tokenVersion = 1L; + + @Column(name = "is_admin", nullable = false) + private boolean admin; + + /** 连续登录失败次数与锁定截止时间,用于防爆破 */ + @Column(name = "failed_attempts", nullable = false) + private int failedAttempts; + + @Column(name = "locked_until") + private Instant lockedUntil; + + @Column(name = "last_login_at") + private Instant lastLoginAt; + + @Column(name = "last_login_ip", length = 64) + private String lastLoginIp; + + /** TOTP 密钥(Base32)。为空表示未生成;生成后需 totpEnabled=true 才生效 */ + @Column(name = "totp_secret", length = 64) + private String totpSecret; + + /** TOTP 是否已完成绑定并启用 */ + @Column(name = "totp_enabled", nullable = false) + private boolean totpEnabled; + + /** + * 近期密码哈希历史,以换行分隔,用于禁止复用最近 {@value #PASSWORD_HISTORY_SIZE} 个密码。 + *

存为单列可避免额外建表,条数很少(默认 5),不影响性能。 + */ + @Column(name = "password_history", length = 1024) + private String passwordHistory; + + /** 密码最后修改时间,用于密码过期策略 */ + @Column(name = "password_changed_at") + private Instant passwordChangedAt; + + protected UserAccount() { + this.userId = null; + this.username = null; + this.passwordHash = null; + this.createdAt = Instant.now(); + } + + public UserAccount(String userId, String username, String passwordHash) { + this.userId = userId; + this.username = username; + this.passwordHash = passwordHash; + this.createdAt = Instant.now(); + } + + public String getUserId() { return userId; } + public String getUsername() { return username; } + public Instant getCreatedAt() { return createdAt; } + + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + + public AccountStatus getStatus() { return status; } + public void setStatus(AccountStatus status) { this.status = status; } + + public Instant getStatusUntil() { return statusUntil; } + public void setStatusUntil(Instant statusUntil) { this.statusUntil = statusUntil; } + + public String getStatusReason() { return statusReason; } + public void setStatusReason(String statusReason) { this.statusReason = statusReason; } + + public long getTokenVersion() { return tokenVersion; } + public void setTokenVersion(long tokenVersion) { this.tokenVersion = tokenVersion; } + + public boolean isAdmin() { return admin; } + public void setAdmin(boolean admin) { this.admin = admin; } + + public int getFailedAttempts() { return failedAttempts; } + public void setFailedAttempts(int failedAttempts) { this.failedAttempts = failedAttempts; } + + public Instant getLockedUntil() { return lockedUntil; } + public void setLockedUntil(Instant lockedUntil) { this.lockedUntil = lockedUntil; } + + public Instant getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(Instant lastLoginAt) { this.lastLoginAt = lastLoginAt; } + + public String getLastLoginIp() { return lastLoginIp; } + public void setLastLoginIp(String lastLoginIp) { this.lastLoginIp = lastLoginIp; } + + public String getTotpSecret() { return totpSecret; } + public void setTotpSecret(String totpSecret) { this.totpSecret = totpSecret; } + + public boolean isTotpEnabled() { return totpEnabled; } + public void setTotpEnabled(boolean totpEnabled) { this.totpEnabled = totpEnabled; } + + public String getPasswordHistory() { return passwordHistory; } + public void setPasswordHistory(String passwordHistory) { this.passwordHistory = passwordHistory; } + + public Instant getPasswordChangedAt() { return passwordChangedAt; } + public void setPasswordChangedAt(Instant passwordChangedAt) { this.passwordChangedAt = passwordChangedAt; } + + /** 返回历史密码哈希列表(最新在前)。 */ + public List passwordHistoryList() { + if (passwordHistory == null || passwordHistory.isBlank()) { + return List.of(); + } + return Arrays.stream(passwordHistory.split("\n")) + .filter(s -> !s.isBlank()) + .toList(); + } + + /** + * 将当前密码哈希追加进历史,并裁剪到 {@value #PASSWORD_HISTORY_SIZE} 条。 + */ + public void pushPasswordHistory(String hash) { + if (hash == null || hash.isBlank()) { + return; + } + List history = new ArrayList<>(); + history.add(hash); + for (String old : passwordHistoryList()) { + if (history.size() >= PASSWORD_HISTORY_SIZE) { + break; + } + if (!old.equals(hash)) { + history.add(old); + } + } + this.passwordHistory = String.join("\n", history); + } + + /** + * 判断账号当前是否可用。SUSPENDED 且已过期时视为可用(由调用方负责回写状态)。 + */ + public boolean isUsable() { + if (status == AccountStatus.BANNED) { + return false; + } + if (status == AccountStatus.SUSPENDED) { + return statusUntil != null && Instant.now().isAfter(statusUntil); + } + return true; + } + + /** 当前是否处于登录失败锁定期。 */ + public boolean isLocked() { + return lockedUntil != null && Instant.now().isBefore(lockedUntil); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/AbuseReportRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/AbuseReportRepository.java new file mode 100644 index 0000000..f0fb1a7 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/AbuseReportRepository.java @@ -0,0 +1,15 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.AbuseReport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AbuseReportRepository extends JpaRepository { + + List findByReportedUserId(String reportedUserId); + + List findByStatus(AbuseReport.ReportStatus status); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceAccountRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceAccountRepository.java new file mode 100644 index 0000000..df0ce6c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceAccountRepository.java @@ -0,0 +1,13 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.DeviceAccount; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DeviceAccountRepository extends JpaRepository { + Optional findBySn(String sn); + boolean existsBySn(String sn); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceAllowlistRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceAllowlistRepository.java new file mode 100644 index 0000000..669cb0d --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceAllowlistRepository.java @@ -0,0 +1,13 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.DeviceAllowlist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface DeviceAllowlistRepository extends JpaRepository { + List findAll(); + boolean existsBySn(String sn); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceBindingRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceBindingRepository.java new file mode 100644 index 0000000..3d4e433 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceBindingRepository.java @@ -0,0 +1,20 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.DeviceBinding; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeviceBindingRepository extends JpaRepository { + + List findByDeviceUid(String deviceUid); + + List findByUserId(String userId); + + Optional findByDeviceUidAndUserId(String deviceUid, String userId); + + List findByDeviceUidAndStatus(String deviceUid, DeviceBinding.BindingStatus status); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceBlacklistRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceBlacklistRepository.java new file mode 100644 index 0000000..016e95e --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/DeviceBlacklistRepository.java @@ -0,0 +1,19 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.DeviceBlacklistEntry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeviceBlacklistRepository + extends JpaRepository { + + List findByDeviceUid(String deviceUid); + + Optional findByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId); + + boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/LoginSessionRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/LoginSessionRepository.java new file mode 100644 index 0000000..689a891 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/LoginSessionRepository.java @@ -0,0 +1,25 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.LoginSession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface LoginSessionRepository extends JpaRepository { + List findByPrincipalId(String principalId); + List findByPrincipalIdAndPrincipalType(String principalId, com.ttstd.signaling.model.PrincipalType type); + void deleteBySessionId(String sessionId); + long countByPrincipalIdAndPrincipalType(String principalId, com.ttstd.signaling.model.PrincipalType type); + + @Modifying + @Query("UPDATE LoginSession s SET s.revoked = true WHERE s.principalId = :principalId") + int revokeByPrincipalId(String principalId); + + @Modifying + @Query("UPDATE LoginSession s SET s.revoked = true WHERE s.sessionId = :sessionId") + int revokeBySessionId(String sessionId); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/PairingCodeRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/PairingCodeRepository.java new file mode 100644 index 0000000..7c79215 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/PairingCodeRepository.java @@ -0,0 +1,13 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.PairingCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PairingCodeRepository extends JpaRepository { + + Optional findByCodeHash(String codeHash); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/UserAccountRepository.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/UserAccountRepository.java new file mode 100644 index 0000000..69f3010 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/repository/UserAccountRepository.java @@ -0,0 +1,13 @@ +package com.ttstd.signaling.repository; + +import com.ttstd.signaling.model.UserAccount; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserAccountRepository extends JpaRepository { + Optional findByUsername(String username); + boolean existsByUsername(String username); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/AuthException.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/AuthException.java new file mode 100644 index 0000000..13f4ddc --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/AuthException.java @@ -0,0 +1,53 @@ +package com.ttstd.signaling.security; + +/** + * 鉴权/账号相关业务异常。 + * + *

{@code publicMessage} 是可返回给客户端的模糊提示, + * {@code detail} 仅用于服务端日志,避免向外泄露账号是否存在、设备是否在线等信息。 + */ +public class AuthException extends RuntimeException { + + private final int status; + private final String code; + private final String publicMessage; + + public AuthException(int status, String code, String publicMessage) { + this(status, code, publicMessage, publicMessage); + } + + public AuthException(int status, String code, String publicMessage, String detail) { + super(detail); + this.status = status; + this.code = code; + this.publicMessage = publicMessage; + } + + public int getStatus() { return status; } + public String getCode() { return code; } + public String getPublicMessage() { return publicMessage; } + + public static AuthException unauthorized(String detail) { + return new AuthException(401, "UNAUTHORIZED", "认证失败", detail); + } + + public static AuthException forbidden(String publicMessage, String detail) { + return new AuthException(403, "FORBIDDEN", publicMessage, detail); + } + + public static AuthException forbidden(String publicMessage) { + return new AuthException(403, "FORBIDDEN", publicMessage, publicMessage); + } + + public static AuthException badRequest(String publicMessage) { + return new AuthException(400, "BAD_REQUEST", publicMessage); + } + + public static AuthException notFound(String publicMessage) { + return new AuthException(404, "NOT_FOUND", publicMessage); + } + + public static AuthException tooManyRequests(String publicMessage) { + return new AuthException(429, "TOO_MANY_REQUESTS", publicMessage); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/AuthHandshakeInterceptor.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/AuthHandshakeInterceptor.java new file mode 100644 index 0000000..38ea520 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/AuthHandshakeInterceptor.java @@ -0,0 +1,193 @@ +package com.ttstd.signaling.security; + +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.service.AccountService; +import com.ttstd.signaling.service.DeviceIdentityService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +/** + * WebSocket 握手鉴权拦截器。 + * + *

连接建立前完成身份认定,未通过者直接以 401 拒绝握手,杜绝匿名连接。 + * 认证通过后将 {@link AuthPrincipal} 写入会话属性,后续信令处理一律以此为准, + * 客户端自报的 fromDeviceId / deviceType 不再被信任。 + * + *

令牌传递优先级: + *

    + *
  1. {@code Sec-WebSocket-Protocol: signal.v1, auth.}(推荐,不进访问日志)
  2. + *
  3. {@code Authorization: Bearer }(非浏览器客户端可用)
  4. + *
  5. URL 查询参数 {@code ?token=}(兼容用途,会告警)
  6. + *
+ */ +@Component +public class AuthHandshakeInterceptor implements HandshakeInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(AuthHandshakeInterceptor.class); + + /** 会话属性键:已鉴权主体 */ + public static final String ATTR_PRINCIPAL = "authPrincipal"; + /** 会话属性键:协商选择的子协议 */ + public static final String ATTR_SELECTED_PROTOCOL = "selectedProtocol"; + + private static final String SUBPROTOCOL = "signal.v1"; + private static final String AUTH_PREFIX = "auth."; + + /** 单 IP 握手频率限制:60 秒内最多 30 次 */ + private static final int HANDSHAKE_LIMIT = 30; + private static final long HANDSHAKE_WINDOW_SECONDS = 60; + + private final AccountService accountService; + private final DeviceIdentityService deviceIdentityService; + private final RateLimiter rateLimiter; + + public AuthHandshakeInterceptor(AccountService accountService, + DeviceIdentityService deviceIdentityService, + RateLimiter rateLimiter) { + this.accountService = accountService; + this.deviceIdentityService = deviceIdentityService; + this.rateLimiter = rateLimiter; + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes) { + String clientIp = resolveClientIp(request); + + if (!rateLimiter.tryAcquire("ws:" + clientIp, HANDSHAKE_LIMIT, HANDSHAKE_WINDOW_SECONDS)) { + logger.warn("握手请求过于频繁,已拒绝:ip={}", clientIp); + response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS); + return false; + } + + String token = extractToken(request); + if (token == null || token.isBlank()) { + logger.warn("握手被拒绝:未携带令牌,ip={}", clientIp); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + + AuthPrincipal principal = resolvePrincipal(token, clientIp); + if (principal == null) { + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + + attributes.put(ATTR_PRINCIPAL, principal); + // 若客户端使用子协议方式传递令牌,需回显子协议名完成协商 + if (requestsSubprotocol(request)) { + attributes.put(ATTR_SELECTED_PROTOCOL, SUBPROTOCOL); + } + + logger.info("握手鉴权通过:{} {} (deviceId={}, ip={})", + principal.principalType(), principal.displayName(), principal.deviceId(), clientIp); + return true; + } + + /** + * 依次尝试按用户令牌、设备令牌解析身份。 + * 两者均失败时统一返回 null,不向客户端区分失败原因。 + */ + private AuthPrincipal resolvePrincipal(String token, String clientIp) { + try { + return accountService.authenticate(token); + } catch (AuthException userEx) { + try { + return deviceIdentityService.authenticate(token); + } catch (AuthException deviceEx) { + logger.warn("握手鉴权失败,ip={},用户令牌校验:{};设备令牌校验:{}", + clientIp, userEx.getMessage(), deviceEx.getMessage()); + return null; + } + } + } + + private boolean requestsSubprotocol(ServerHttpRequest request) { + List protocols = request.getHeaders().get("Sec-WebSocket-Protocol"); + if (protocols == null) { + return false; + } + for (String raw : protocols) { + for (String part : raw.split(",")) { + if (SUBPROTOCOL.equals(part.trim())) { + return true; + } + } + } + return false; + } + + /** + * 提取令牌,优先使用不会被写入访问日志的传递方式。 + */ + private String extractToken(ServerHttpRequest request) { + // 1) Sec-WebSocket-Protocol: signal.v1, auth. + List protocols = request.getHeaders().get("Sec-WebSocket-Protocol"); + if (protocols != null) { + for (String raw : protocols) { + for (String part : raw.split(",")) { + String value = part.trim(); + if (value.startsWith(AUTH_PREFIX) && value.length() > AUTH_PREFIX.length()) { + return value.substring(AUTH_PREFIX.length()); + } + } + } + } + + // 2) Authorization: Bearer + String authorization = request.getHeaders().getFirst("Authorization"); + if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + return authorization.substring(7).trim(); + } + + // 3) URL 查询参数(兼容旧客户端,存在写入日志的风险) + URI uri = request.getURI(); + String query = uri.getQuery(); + if (query != null) { + for (String pair : query.split("&")) { + int idx = pair.indexOf('='); + if (idx > 0 && "token".equals(pair.substring(0, idx))) { + logger.warn("客户端通过 URL 查询参数传递令牌,存在日志泄露风险,建议改用 Sec-WebSocket-Protocol"); + return java.net.URLDecoder.decode( + pair.substring(idx + 1), java.nio.charset.StandardCharsets.UTF_8); + } + } + } + return null; + } + + private String resolveClientIp(ServerHttpRequest request) { + String forwarded = request.getHeaders().getFirst("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + int comma = forwarded.indexOf(','); + return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim(); + } + if (request instanceof ServletServerHttpRequest servletRequest) { + return servletRequest.getServletRequest().getRemoteAddr(); + } + return request.getRemoteAddress() == null + ? "unknown" + : request.getRemoteAddress().getAddress().getHostAddress(); + } + + @Override + public void afterHandshake(ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception) { + // 无需处理 + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/JwtService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/JwtService.java new file mode 100644 index 0000000..93d607d --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/JwtService.java @@ -0,0 +1,185 @@ +package com.ttstd.signaling.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 轻量 JWT(HS256)签发与校验服务。 + * + *

不引入额外 JWT 库,直接基于 JDK 的 HMAC-SHA256 实现,减少依赖面。 + * 签名比对使用 {@link MessageDigest#isEqual} 以规避时序侧信道。 + */ +@Service +public class JwtService { + + private static final Logger logger = LoggerFactory.getLogger(JwtService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64URL_DEC = Base64.getUrlDecoder(); + + /** 令牌用途,防止 access/refresh/device 令牌互相冒用 */ + public static final String PURPOSE_ACCESS = "access"; + public static final String PURPOSE_REFRESH = "refresh"; + public static final String PURPOSE_DEVICE = "device"; + + private final SecurityProperties properties; + private final byte[] signingKey; + + public JwtService(SecurityProperties properties) { + this.properties = properties; + String configured = properties.getJwt().getSecret(); + if (configured == null || configured.isBlank()) { + byte[] random = new byte[48]; + new SecureRandom().nextBytes(random); + this.signingKey = random; + logger.warn("未配置 security.jwt.secret,已生成随机密钥。服务重启后所有令牌将失效," + + "且多实例部署无法互认,生产环境请通过环境变量 JWT_SECRET 配置固定密钥。"); + } else if (configured.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("security.jwt.secret 长度不足,至少需要 32 字节"); + } else { + this.signingKey = configured.getBytes(StandardCharsets.UTF_8); + } + } + + /** + * 签发令牌。 + * + * @param subject 主体 ID + * @param purpose 令牌用途 + * @param ttlSeconds 有效期(秒) + * @param tokenVersion 凭据版本号,与账号当前版本不一致即视为失效 + * @param extraClaims 附加声明 + */ + public String issue(String subject, + String purpose, + long ttlSeconds, + long tokenVersion, + Map extraClaims) { + Instant now = Instant.now(); + Map header = new LinkedHashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + + Map claims = new LinkedHashMap<>(); + claims.put("iss", properties.getJwt().getIssuer()); + claims.put("sub", subject); + claims.put("iat", now.getEpochSecond()); + claims.put("exp", now.plusSeconds(ttlSeconds).getEpochSecond()); + claims.put("jti", randomId()); + claims.put("pur", purpose); + claims.put("ver", tokenVersion); + if (extraClaims != null) { + claims.putAll(extraClaims); + } + + try { + String headerPart = B64URL.encodeToString(MAPPER.writeValueAsBytes(header)); + String payloadPart = B64URL.encodeToString(MAPPER.writeValueAsBytes(claims)); + String signingInput = headerPart + "." + payloadPart; + String signature = B64URL.encodeToString(hmacSha256(signingInput)); + return signingInput + "." + signature; + } catch (Exception e) { + throw new IllegalStateException("签发令牌失败", e); + } + } + + /** + * 校验令牌签名、有效期与用途,返回声明集合。 + * + * @throws AuthException 校验失败 + */ + public Map verify(String token, String expectedPurpose) { + if (token == null || token.isBlank()) { + throw AuthException.unauthorized("令牌为空"); + } + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw AuthException.unauthorized("令牌格式非法"); + } + + String signingInput = parts[0] + "." + parts[1]; + byte[] expected = hmacSha256(signingInput); + byte[] actual; + try { + actual = B64URL_DEC.decode(parts[2]); + } catch (IllegalArgumentException e) { + throw AuthException.unauthorized("令牌签名编码非法"); + } + // 常量时间比较,避免时序侧信道 + if (!MessageDigest.isEqual(expected, actual)) { + throw AuthException.unauthorized("令牌签名无效"); + } + + Map claims; + try { + byte[] payload = B64URL_DEC.decode(parts[1]); + @SuppressWarnings("unchecked") + Map parsed = MAPPER.readValue(payload, Map.class); + claims = parsed; + } catch (Exception e) { + throw AuthException.unauthorized("令牌载荷解析失败"); + } + + long skew = properties.getJwt().getClockSkewSeconds(); + long now = Instant.now().getEpochSecond(); + + Object exp = claims.get("exp"); + if (!(exp instanceof Number expNum) || now > expNum.longValue() + skew) { + throw AuthException.unauthorized("令牌已过期"); + } + Object iat = claims.get("iat"); + if (iat instanceof Number iatNum && now + skew < iatNum.longValue()) { + throw AuthException.unauthorized("令牌签发时间异常"); + } + if (!properties.getJwt().getIssuer().equals(claims.get("iss"))) { + throw AuthException.unauthorized("令牌签发方不匹配"); + } + if (expectedPurpose != null && !expectedPurpose.equals(claims.get("pur"))) { + throw AuthException.unauthorized("令牌用途不匹配"); + } + return claims; + } + + public static String claimAsString(Map claims, String key) { + Object v = claims.get(key); + return v == null ? null : String.valueOf(v); + } + + public static long claimAsLong(Map claims, String key, long defaultValue) { + Object v = claims.get(key); + return v instanceof Number n ? n.longValue() : defaultValue; + } + + public Map newClaims() { + return new HashMap<>(); + } + + private byte[] hmacSha256(String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("HMAC 计算失败", e); + } + } + + private static String randomId() { + byte[] buf = new byte[16]; + new SecureRandom().nextBytes(buf); + return B64URL.encodeToString(buf); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/RateLimiter.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/RateLimiter.java new file mode 100644 index 0000000..754e60c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/RateLimiter.java @@ -0,0 +1,62 @@ +package com.ttstd.signaling.security; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 简易固定窗口限流器,用于登录、激活、握手等敏感入口的防爆破。 + * + *

当前为单机内存实现;多实例部署时应替换为 Redis 计数。 + */ +@Component +public class RateLimiter { + + private static final class Window { + final AtomicInteger count = new AtomicInteger(); + volatile Instant resetAt; + + Window(Instant resetAt) { + this.resetAt = resetAt; + } + } + + private final Map windows = new ConcurrentHashMap<>(); + + /** + * 尝试消费一次配额。 + * + * @param key 限流键(如 "login:" + ip) + * @param maxRequests 窗口内最大次数 + * @param windowSeconds 窗口长度(秒) + * @return true 表示允许,false 表示已超限 + */ + public boolean tryAcquire(String key, int maxRequests, long windowSeconds) { + Instant now = Instant.now(); + Window window = windows.compute(key, (k, existing) -> { + if (existing == null || now.isAfter(existing.resetAt)) { + return new Window(now.plusSeconds(windowSeconds)); + } + return existing; + }); + return window.count.incrementAndGet() <= maxRequests; + } + + /** 重置指定键的计数(例如登录成功后清空失败计数)。 */ + public void reset(String key) { + windows.remove(key); + } + + /** 清理已过期窗口,避免内存无限增长。由定时任务调用。 */ + public void evictExpired() { + Instant now = Instant.now(); + windows.entrySet().removeIf(e -> now.isAfter(e.getValue().resetAt)); + } + + public int size() { + return windows.size(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/SecurityProperties.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/SecurityProperties.java new file mode 100644 index 0000000..9641d9f --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/SecurityProperties.java @@ -0,0 +1,118 @@ +package com.ttstd.signaling.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 安全相关配置项,对应 application.yml 中的 {@code security.*}。 + */ +@ConfigurationProperties(prefix = "security") +public class SecurityProperties { + + private final Jwt jwt = new Jwt(); + private final Device device = new Device(); + private final Account account = new Account(); + private final WebSocket websocket = new WebSocket(); + private final Turn turn = new Turn(); + + public Jwt getJwt() { return jwt; } + public Device getDevice() { return device; } + public Account getAccount() { return account; } + public WebSocket getWebsocket() { return websocket; } + public Turn getTurn() { return turn; } + + public static class Jwt { + private String secret; + private String issuer = "webrtc-signal-server"; + private long accessTokenTtlSeconds = 900; + private long refreshTokenTtlSeconds = 604800; + private long deviceTokenTtlSeconds = 900; + private long clockSkewSeconds = 60; + + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + + public long getAccessTokenTtlSeconds() { return accessTokenTtlSeconds; } + public void setAccessTokenTtlSeconds(long v) { this.accessTokenTtlSeconds = v; } + + public long getRefreshTokenTtlSeconds() { return refreshTokenTtlSeconds; } + public void setRefreshTokenTtlSeconds(long v) { this.refreshTokenTtlSeconds = v; } + + public long getDeviceTokenTtlSeconds() { return deviceTokenTtlSeconds; } + public void setDeviceTokenTtlSeconds(long v) { this.deviceTokenTtlSeconds = v; } + + public long getClockSkewSeconds() { return clockSkewSeconds; } + public void setClockSkewSeconds(long v) { this.clockSkewSeconds = v; } + } + + public static class Device { + private String provisionSecret; + private long provisionSkewSeconds = 300; + private boolean snAllowlistEnabled = false; + + public String getProvisionSecret() { return provisionSecret; } + public void setProvisionSecret(String provisionSecret) { this.provisionSecret = provisionSecret; } + + public long getProvisionSkewSeconds() { return provisionSkewSeconds; } + public void setProvisionSkewSeconds(long v) { this.provisionSkewSeconds = v; } + + public boolean isSnAllowlistEnabled() { return snAllowlistEnabled; } + public void setSnAllowlistEnabled(boolean v) { this.snAllowlistEnabled = v; } + } + + public static class Account { + private boolean registrationEnabled = true; + private int maxFailedAttempts = 5; + private long lockDurationSeconds = 900; + private int maxConcurrentSessions = 5; + private String bootstrapUsername = "admin"; + private String bootstrapPassword; + + public boolean isRegistrationEnabled() { return registrationEnabled; } + public void setRegistrationEnabled(boolean v) { this.registrationEnabled = v; } + + public int getMaxFailedAttempts() { return maxFailedAttempts; } + public void setMaxFailedAttempts(int v) { this.maxFailedAttempts = v; } + + public long getLockDurationSeconds() { return lockDurationSeconds; } + public void setLockDurationSeconds(long v) { this.lockDurationSeconds = v; } + + public int getMaxConcurrentSessions() { return maxConcurrentSessions; } + public void setMaxConcurrentSessions(int v) { this.maxConcurrentSessions = v; } + + public String getBootstrapUsername() { return bootstrapUsername; } + public void setBootstrapUsername(String v) { this.bootstrapUsername = v; } + + public String getBootstrapPassword() { return bootstrapPassword; } + public void setBootstrapPassword(String v) { this.bootstrapPassword = v; } + } + + public static class WebSocket { + private String allowedOrigins = "*"; + + public String getAllowedOrigins() { return allowedOrigins; } + public void setAllowedOrigins(String v) { this.allowedOrigins = v; } + } + + public static class Turn { + /** TURN 共享密钥(短期凭证的 HMAC key),生产环境务必通过环境变量注入 */ + private String sharedSecret; + /** TURN 服务器地址列表,逗号分隔,如 turn:turn.ttstd.com:3478?transport=udp */ + private String urls = ""; + /** 短期凭证有效期(秒),到点后凭证失效,客户端需重新获取 */ + private long ttlSeconds = 3600; + /** 是否启用 TURN 凭证发放 */ + private boolean enabled = false; + + public String getSharedSecret() { return sharedSecret; } + public void setSharedSecret(String v) { this.sharedSecret = v; } + public String getUrls() { return urls; } + public void setUrls(String v) { this.urls = v; } + public long getTtlSeconds() { return ttlSeconds; } + public void setTtlSeconds(long v) { this.ttlSeconds = v; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean v) { this.enabled = v; } + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/SubProtocolHandshakeHandler.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/SubProtocolHandshakeHandler.java new file mode 100644 index 0000000..f72a77d --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/SubProtocolHandshakeHandler.java @@ -0,0 +1,44 @@ +package com.ttstd.signaling.security; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.util.List; +import java.util.Map; + +/** + * 子协议协商处理器。 + * + *

客户端以 {@code Sec-WebSocket-Protocol: signal.v1, auth.} 传递令牌时, + * 服务端必须在响应中回显一个已选择的子协议,否则浏览器会判定协商失败并断开连接。 + * 此处固定回显 {@code signal.v1},绝不回显携带令牌的那一项。 + */ +@Component +public class SubProtocolHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected String selectProtocol(List requestedProtocols, WebSocketHandler webSocketHandler) { + if (requestedProtocols == null) { + return null; + } + for (String protocol : requestedProtocols) { + if ("signal.v1".equals(protocol.trim())) { + return "signal.v1"; + } + } + return null; + } + + @Override + protected java.security.Principal determineUser(ServerHttpRequest request, + WebSocketHandler wsHandler, + Map attributes) { + Object principal = attributes.get(AuthHandshakeInterceptor.ATTR_PRINCIPAL); + if (principal instanceof com.ttstd.signaling.model.AuthPrincipal auth) { + return auth::deviceId; + } + return null; + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/TokenUtils.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/TokenUtils.java new file mode 100644 index 0000000..453b1bc --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/TokenUtils.java @@ -0,0 +1,57 @@ +package com.ttstd.signaling.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * 随机 ID / 密钥生成与哈希工具。 + */ +public final class TokenUtils { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); + private static final char[] BASE62 = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + + private TokenUtils() { + } + + /** 生成高熵、不可枚举的 Base62 字符串。 */ + public static String randomBase62(int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(BASE62[RANDOM.nextInt(BASE62.length)]); + } + return sb.toString(); + } + + /** 生成 URL-safe 的随机密钥(用于 deviceSecret / refreshToken)。 */ + public static String randomSecret(int bytes) { + byte[] buf = new byte[bytes]; + RANDOM.nextBytes(buf); + return B64URL.encodeToString(buf); + } + + /** 对不可逆凭据做 SHA-256 摘要(适用于高熵随机串,无需加盐慢哈希)。 */ + public static String sha256(String raw) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return B64URL.encodeToString(md.digest(raw.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 不可用", e); + } + } + + /** 常量时间字符串比较。 */ + public static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), + b.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/TotpService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/TotpService.java new file mode 100644 index 0000000..0584c5c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/security/TotpService.java @@ -0,0 +1,147 @@ +package com.ttstd.signaling.security; + +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Instant; + +/** + * TOTP(RFC 6238)双因子认证服务。 + * + *

基于 HMAC-SHA1、30 秒时间步长、6 位动态码,兼容 Google Authenticator / + * Microsoft Authenticator / 1Password 等主流认证器。 + * + *

校验时允许前后各一个时间窗口(±30 秒),以容忍设备时钟偏差。 + */ +@Service +public class TotpService { + + private static final int DIGITS = 6; + private static final int PERIOD_SECONDS = 30; + /** 允许的时间窗口偏移数量,1 表示接受前后各 30 秒 */ + private static final int WINDOW = 1; + private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + private final SecureRandom random = new SecureRandom(); + + /** 生成 Base32 编码的 TOTP 密钥(160 bit)。 */ + public String generateSecret() { + byte[] buf = new byte[20]; + random.nextBytes(buf); + return base32Encode(buf); + } + + /** + * 构造 otpauth:// URI,供客户端生成二维码。 + */ + public String buildOtpAuthUri(String issuer, String accountName, String secret) { + String encodedIssuer = URLEncoder.encode(issuer, StandardCharsets.UTF_8); + String encodedAccount = URLEncoder.encode(accountName, StandardCharsets.UTF_8); + return "otpauth://totp/" + encodedIssuer + ":" + encodedAccount + + "?secret=" + secret + + "&issuer=" + encodedIssuer + + "&algorithm=SHA1" + + "&digits=" + DIGITS + + "&period=" + PERIOD_SECONDS; + } + + /** + * 校验动态码。允许 ±{@value #WINDOW} 个时间窗口的偏差。 + */ + public boolean verify(String secret, String code) { + if (secret == null || code == null) { + return false; + } + String normalized = code.trim().replace(" ", ""); + if (normalized.length() != DIGITS || !normalized.chars().allMatch(Character::isDigit)) { + return false; + } + byte[] key; + try { + key = base32Decode(secret); + } catch (IllegalArgumentException e) { + return false; + } + long counter = Instant.now().getEpochSecond() / PERIOD_SECONDS; + for (int offset = -WINDOW; offset <= WINDOW; offset++) { + String expected = generateCode(key, counter + offset); + // 常量时间比较,避免时序侧信道 + if (TokenUtils.constantTimeEquals(expected, normalized)) { + return true; + } + } + return false; + } + + /** 按 RFC 4226 生成 HOTP 码。 */ + private String generateCode(byte[] key, long counter) { + byte[] data = new byte[8]; + long value = counter; + for (int i = 7; i >= 0; i--) { + data[i] = (byte) (value & 0xFF); + value >>>= 8; + } + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(key, "HmacSHA1")); + byte[] hash = mac.doFinal(data); + + int offset = hash[hash.length - 1] & 0x0F; + int binary = ((hash[offset] & 0x7F) << 24) + | ((hash[offset + 1] & 0xFF) << 16) + | ((hash[offset + 2] & 0xFF) << 8) + | (hash[offset + 3] & 0xFF); + int otp = binary % (int) Math.pow(10, DIGITS); + return String.format("%0" + DIGITS + "d", otp); + } catch (Exception e) { + throw new IllegalStateException("TOTP 计算失败", e); + } + } + + // ==================== Base32 ==================== + + static String base32Encode(byte[] data) { + StringBuilder sb = new StringBuilder(); + int buffer = 0; + int bitsLeft = 0; + for (byte b : data) { + buffer = (buffer << 8) | (b & 0xFF); + bitsLeft += 8; + while (bitsLeft >= 5) { + sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F)); + bitsLeft -= 5; + } + } + if (bitsLeft > 0) { + sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F)); + } + return sb.toString(); + } + + static byte[] base32Decode(String encoded) { + String normalized = encoded.trim().replace("=", "").toUpperCase(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("空的 Base32 字符串"); + } + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + int buffer = 0; + int bitsLeft = 0; + for (char c : normalized.toCharArray()) { + int index = BASE32_ALPHABET.indexOf(c); + if (index < 0) { + throw new IllegalArgumentException("非法 Base32 字符: " + c); + } + buffer = (buffer << 5) | index; + bitsLeft += 5; + if (bitsLeft >= 8) { + out.write((buffer >> (bitsLeft - 8)) & 0xFF); + bitsLeft -= 8; + } + } + return out.toByteArray(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AbuseReportService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AbuseReportService.java new file mode 100644 index 0000000..e97df5e --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AbuseReportService.java @@ -0,0 +1,40 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.model.AbuseReport; +import com.ttstd.signaling.repository.AbuseReportRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 骚扰举报服务(P2 风控):被控端可对骚扰自己的主控端账号提交举报。 + */ +@Service +public class AbuseReportService { + + private final AbuseReportRepository repository; + + public AbuseReportService(AbuseReportRepository repository) { + this.repository = repository; + } + + public AbuseReport report(String deviceUid, String reportedUserId, String reason) { + AbuseReport report = new AbuseReport(deviceUid, reportedUserId, reason); + return repository.save(report); + } + + public List listPending() { + return repository.findByStatus(AbuseReport.ReportStatus.PENDING); + } + + public List listByReportedUser(String reportedUserId) { + return repository.findByReportedUserId(reportedUserId); + } + + public void handle(Long id, AbuseReport.ReportStatus status) { + repository.findById(id).ifPresent(r -> { + r.setStatus(status); + repository.save(r); + }); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AccountService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AccountService.java new file mode 100644 index 0000000..65ac9b6 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AccountService.java @@ -0,0 +1,608 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.model.AccountStatus; +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.model.DeviceType; +import com.ttstd.signaling.model.LoginSession; +import com.ttstd.signaling.model.PrincipalType; +import com.ttstd.signaling.model.UserAccount; +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.security.JwtService; +import com.ttstd.signaling.security.SecurityProperties; +import com.ttstd.signaling.security.TokenUtils; +import com.ttstd.signaling.security.TotpService; +import com.ttstd.signaling.store.SessionStore; +import com.ttstd.signaling.store.UserStore; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 主控端账号服务:注册、登录、令牌刷新、登出、封禁与强制下线。 + * + *

底层存储通过 {@link UserStore}/{@link SessionStore} 抽象,生产环境使用 MySQL(JPA), + * 测试/演示使用内存实现,调用方无感知。 + */ +@Service +public class AccountService { + + private static final Logger logger = LoggerFactory.getLogger(AccountService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]{3,32}$"); + + /** 常见弱口令黑名单(小写比对)。生产可扩展为从文件加载。 */ + private static final Set WEAK_PASSWORDS = Set.of( + "password", "passw0rd", "password1", "password123", "passw0rd!", + "12345678", "123456789", "1234567890", "qwertyui", "qwerty123", + "abc12345", "admin123", "administrator", "letmein1", "welcome1", + "iloveyou", "sunshine", "princess", "football", "baseball", + "monkey123", "dragon123", "master123", "shadow123", "superman"); + + private final UserStore userStore; + private final SessionStore sessionStore; + private final SecurityProperties properties; + private final JwtService jwtService; + private final TotpService totpService; + private final AuditService auditService; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12); + + /** 会话失效回调:由信令层注册,用于立即断开对应 WebSocket 连接 */ + private volatile SessionRevocationListener revocationListener; + + public AccountService(UserStore userStore, + SessionStore sessionStore, + SecurityProperties properties, + JwtService jwtService, + TotpService totpService, + AuditService auditService) { + this.userStore = userStore; + this.sessionStore = sessionStore; + this.properties = properties; + this.jwtService = jwtService; + this.totpService = totpService; + this.auditService = auditService; + } + + /** 会话/账号失效通知接口。 */ + public interface SessionRevocationListener { + void onRevoked(String principalId, String sessionId, String reason); + } + + public void setRevocationListener(SessionRevocationListener listener) { + this.revocationListener = listener; + } + + @PostConstruct + void bootstrap() { + String username = properties.getAccount().getBootstrapUsername(); + String password = properties.getAccount().getBootstrapPassword(); + if (username == null || username.isBlank()) { + return; + } + if (password == null || password.isBlank()) { + logger.warn("未配置 BOOTSTRAP_ADMIN_PASSWORD,跳过初始管理员账号创建。" + + "可通过环境变量配置后重启,或使用注册接口创建账号。"); + return; + } + if (userStore.existsByUsername(username)) { + return; + } + UserAccount admin = createAccountInternal(username, password); + admin.setAdmin(true); + userStore.save(admin); + logger.info("已创建初始管理员账号: {}", username); + } + + // ==================== 注册 ==================== + + public UserAccount register(String username, String password) { + if (!properties.getAccount().isRegistrationEnabled()) { + throw AuthException.forbidden("当前不开放注册", "registration disabled"); + } + validateUsername(username); + validatePassword(password, username); + return createAccountInternal(username, password); + } + + private UserAccount createAccountInternal(String username, String password) { + String key = username.toLowerCase(Locale.ROOT); + if (userStore.existsByUsername(username)) { + throw AuthException.badRequest("用户名已被占用"); + } + String userId = "usr_" + TokenUtils.randomBase62(20); + UserAccount account = new UserAccount(userId, username, passwordEncoder.encode(password)); + account.setPasswordChangedAt(Instant.now()); + userStore.save(account); + logger.info("账号已创建: {} ({})", username, userId); + auditService.recordUser(userId, AuditService.ACTION_REGISTER, + AuditService.RESULT_SUCCESS, null, "账号注册成功: " + username); + return account; + } + + private void validateUsername(String username) { + if (username == null || !USERNAME_PATTERN.matcher(username).matches()) { + throw AuthException.badRequest("用户名需为 3-32 位字母、数字、下划线、点或连字符"); + } + } + + /** + * 密码强度校验:长度、字符类别、弱口令黑名单、与用户名的相关性。 + */ + private void validatePassword(String password, String username) { + if (password == null || password.length() < 8 || password.length() > 128) { + throw AuthException.badRequest("密码长度需为 8-128 位"); + } + int classes = 0; + if (password.matches(".*[a-z].*")) classes++; + if (password.matches(".*[A-Z].*")) classes++; + if (password.matches(".*\\d.*")) classes++; + if (password.matches(".*[^a-zA-Z0-9].*")) classes++; + if (classes < 2) { + throw AuthException.badRequest("密码需至少包含大小写字母、数字、符号中的两类"); + } + + String lower = password.toLowerCase(Locale.ROOT); + if (WEAK_PASSWORDS.contains(lower)) { + throw AuthException.badRequest("密码过于简单,请更换"); + } + // 密码不得包含用户名(防止 alice/alice123 这类弱口令) + if (username != null && username.length() >= 3 + && lower.contains(username.toLowerCase(Locale.ROOT))) { + throw AuthException.badRequest("密码不能包含用户名"); + } + // 连续或重复字符检测,如 111111 / abcdef + if (hasTrivialSequence(lower)) { + throw AuthException.badRequest("密码不能为连续或重复字符"); + } + } + + /** 检测全部为同一字符,或为连续递增/递减序列。 */ + private static boolean hasTrivialSequence(String password) { + boolean allSame = true; + boolean ascending = true; + boolean descending = true; + for (int i = 1; i < password.length(); i++) { + char prev = password.charAt(i - 1); + char curr = password.charAt(i); + if (curr != prev) allSame = false; + if (curr != prev + 1) ascending = false; + if (curr != prev - 1) descending = false; + } + return allSame || ascending || descending; + } + + // ==================== 登录 ==================== + + /** 兼容旧签名:不带 TOTP 动态码的登录。 */ + public TokenPair login(String username, String password, String ip, String userAgent) { + return login(username, password, null, ip, userAgent); + } + + /** + * 账号密码登录,支持 TOTP 双因子。 + * + *

无论用户名是否存在,失败均返回同一模糊提示,避免账号枚举。 + * + * @param totpCode 动态码;账号未启用 TOTP 时忽略 + */ + public TokenPair login(String username, String password, String totpCode, + String ip, String userAgent) { + UserAccount account = userStore.findByUsername(username).orElse(null); + + if (account == null) { + // 执行一次伪哈希,抹平“用户不存在”与“密码错误”的响应时间差 + passwordEncoder.encode("dummy-password-for-timing"); + auditService.record("USER", username, AuditService.ACTION_LOGIN_FAILED, + null, AuditService.RESULT_FAILURE, ip, "账号不存在"); + throw AuthException.unauthorized("username not found: " + username); + } + if (account.isLocked()) { + auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN_FAILED, + AuditService.RESULT_FAILURE, ip, "账号处于锁定期"); + throw AuthException.tooManyRequests("尝试过于频繁,请稍后再试"); + } + if (!passwordEncoder.matches(password, account.getPasswordHash())) { + recordLoginFailure(account, ip); + throw AuthException.unauthorized("bad password for " + account.getUserId()); + } + + // 第二因子校验 + if (account.isTotpEnabled()) { + if (totpCode == null || totpCode.isBlank()) { + // 用专门的错误码告知客户端需要补充动态码,而非笼统的认证失败 + throw new AuthException(401, "TOTP_REQUIRED", "请输入动态验证码", + "totp required for " + account.getUserId()); + } + if (!totpService.verify(account.getTotpSecret(), totpCode)) { + recordLoginFailure(account, ip); + auditService.recordUser(account.getUserId(), AuditService.ACTION_TOTP_FAILED, + AuditService.RESULT_FAILURE, ip, "动态码校验失败"); + throw AuthException.unauthorized("bad totp for " + account.getUserId()); + } + } + + account = assertAccountUsable(account); + + account.setFailedAttempts(0); + account.setLockedUntil(null); + account.setLastLoginAt(Instant.now()); + account.setLastLoginIp(ip); + userStore.save(account); + + enforceSessionLimit(account.getUserId()); + TokenPair pair = issueTokens(account, ip, userAgent); + auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN, + AuditService.RESULT_SUCCESS, ip, + account.isTotpEnabled() ? "密码+动态码登录" : "密码登录"); + return pair; + } + + private void recordLoginFailure(UserAccount account, String ip) { + int attempts = account.getFailedAttempts() + 1; + account.setFailedAttempts(attempts); + if (attempts >= properties.getAccount().getMaxFailedAttempts()) { + account.setLockedUntil(Instant.now() + .plusSeconds(properties.getAccount().getLockDurationSeconds())); + account.setFailedAttempts(0); + logger.warn("账号 {} 连续登录失败已触发锁定", account.getUserId()); + auditService.recordUser(account.getUserId(), AuditService.ACTION_ACCOUNT_LOCKED, + AuditService.RESULT_FAILURE, ip, "连续登录失败触发锁定"); + } + userStore.save(account); + auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN_FAILED, + AuditService.RESULT_FAILURE, ip, "凭据校验失败"); + } + + /** 校验账号可用性;对已过期的临时封禁自动恢复。返回最新账号对象。 */ + private UserAccount assertAccountUsable(UserAccount account) { + if (account.getStatus() == AccountStatus.SUSPENDED + && account.getStatusUntil() != null + && Instant.now().isAfter(account.getStatusUntil())) { + account.setStatus(AccountStatus.ACTIVE); + account.setStatusUntil(null); + account.setStatusReason(null); + userStore.save(account); + logger.info("账号 {} 临时封禁已到期,自动恢复", account.getUserId()); + } + if (!account.isUsable()) { + String msg = account.getStatus() == AccountStatus.BANNED + ? "账号已被封禁" : "账号已被临时封禁"; + if (account.getStatusReason() != null && !account.getStatusReason().isBlank()) { + msg = msg + ":" + account.getStatusReason(); + } + throw AuthException.forbidden(msg, "account not usable: " + account.getUserId()); + } + return account; + } + + /** 超出最大并发会话数时,踢掉最旧的会话。 */ + private void enforceSessionLimit(String userId) { + int max = properties.getAccount().getMaxConcurrentSessions(); + if (max <= 0) { + return; + } + List active = sessionStore.listByPrincipalAndType(userId, PrincipalType.USER).stream() + .filter(LoginSession::isActive) + .sorted(Comparator.comparing(LoginSession::getCreatedAt)) + .toList(); + int excess = active.size() - (max - 1); + for (int i = 0; i < excess && i < active.size(); i++) { + LoginSession old = active.get(i); + old.setRevoked(true); + sessionStore.save(old); + notifyRevoked(userId, old.getSessionId(), "会话数超限,最旧会话已下线"); + logger.info("账号 {} 会话数超限,已踢出最旧会话 {}", userId, old.getSessionId()); + } + } + + private TokenPair issueTokens(UserAccount account, String ip, String userAgent) { + String sessionId = "ses_" + TokenUtils.randomBase62(20); + String refreshToken = TokenUtils.randomSecret(32); + Instant refreshExpiry = Instant.now() + .plusSeconds(properties.getJwt().getRefreshTokenTtlSeconds()); + + LoginSession session = new LoginSession( + sessionId, account.getUserId(), PrincipalType.USER, + TokenUtils.sha256(refreshToken), refreshExpiry, ip, userAgent); + sessionStore.save(session); + + String accessToken = buildAccessToken(account, sessionId); + return new TokenPair(accessToken, sessionId + "." + refreshToken, + properties.getJwt().getAccessTokenTtlSeconds(), + sessionId, account.getUserId(), account.getUsername()); + } + + private String buildAccessToken(UserAccount account, String sessionId) { + Map claims = jwtService.newClaims(); + claims.put("typ", PrincipalType.USER.name()); + claims.put("sid", sessionId); + claims.put("name", account.getUsername()); + claims.put("adm", account.isAdmin()); + return jwtService.issue(account.getUserId(), JwtService.PURPOSE_ACCESS, + properties.getJwt().getAccessTokenTtlSeconds(), + account.getTokenVersion(), claims); + } + + // ==================== 刷新 ==================== + + public TokenPair refresh(String compositeRefreshToken, String ip, String userAgent) { + if (compositeRefreshToken == null || !compositeRefreshToken.contains(".")) { + throw AuthException.unauthorized("刷新令牌格式非法"); + } + int idx = compositeRefreshToken.indexOf('.'); + String sessionId = compositeRefreshToken.substring(0, idx); + String rawToken = compositeRefreshToken.substring(idx + 1); + + LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null); + if (session == null || !session.isActive()) { + throw AuthException.unauthorized("刷新令牌无效或已过期"); + } + if (!TokenUtils.constantTimeEquals(session.getRefreshTokenHash(), TokenUtils.sha256(rawToken))) { + session.setRevoked(true); + sessionStore.save(session); + notifyRevoked(session.getPrincipalId(), sessionId, "检测到刷新令牌异常复用,会话已终止"); + logger.warn("检测到刷新令牌复用,已吊销会话 {}(主体 {})", + sessionId, session.getPrincipalId()); + auditService.recordUser(session.getPrincipalId(), AuditService.ACTION_REFRESH_REUSE, + AuditService.RESULT_FAILURE, ip, + "刷新令牌复用,疑似泄露,已吊销会话 " + sessionId); + throw AuthException.unauthorized("刷新令牌无效"); + } + + UserAccount account = userStore.findByUserId(session.getPrincipalId()).orElse(null); + if (account == null) { + throw AuthException.unauthorized("账号不存在"); + } + account = assertAccountUsable(account); + + String newRefresh = TokenUtils.randomSecret(32); + session.setRefreshTokenHash(TokenUtils.sha256(newRefresh)); + session.setRefreshExpiresAt(Instant.now() + .plusSeconds(properties.getJwt().getRefreshTokenTtlSeconds())); + session.setLastSeenAt(Instant.now()); + sessionStore.save(session); + + String accessToken = buildAccessToken(account, sessionId); + return new TokenPair(accessToken, sessionId + "." + newRefresh, + properties.getJwt().getAccessTokenTtlSeconds(), + sessionId, account.getUserId(), account.getUsername()); + } + + // ==================== 令牌校验 ==================== + + public AuthPrincipal authenticate(String accessToken) { + Map claims = jwtService.verify(accessToken, JwtService.PURPOSE_ACCESS); + if (!PrincipalType.USER.name().equals(JwtService.claimAsString(claims, "typ"))) { + throw AuthException.unauthorized("令牌主体类型不匹配"); + } + String userId = JwtService.claimAsString(claims, "sub"); + UserAccount account = userStore.findByUserId(userId).orElse(null); + if (account == null) { + throw AuthException.unauthorized("账号不存在: " + userId); + } + long ver = JwtService.claimAsLong(claims, "ver", -1); + if (ver != account.getTokenVersion()) { + throw AuthException.unauthorized("令牌已失效(凭据版本变更)"); + } + account = assertAccountUsable(account); + + String sessionId = JwtService.claimAsString(claims, "sid"); + LoginSession session = sessionId == null ? null : sessionStore.findBySessionId(sessionId).orElse(null); + if (session == null || !session.isActive()) { + throw AuthException.unauthorized("会话已结束"); + } + session.setLastSeenAt(Instant.now()); + sessionStore.save(session); + + String signalDeviceId = "ctl_" + sessionId; + return new AuthPrincipal(PrincipalType.USER, userId, signalDeviceId, + DeviceType.CONTROLLER, sessionId, account.getUsername(), account.isAdmin()); + } + + // ==================== 登出 / 封禁 / 踢线 ==================== + + public void logout(String sessionId) { + LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null); + if (session != null) { + session.setRevoked(true); + sessionStore.save(session); + notifyRevoked(session.getPrincipalId(), sessionId, "已登出"); + logger.info("会话 {} 已登出", sessionId); + } + } + + /** 吊销指定账号的全部会话并使所有令牌失效。 */ + public void revokeAllSessions(String userId, String reason) { + UserAccount account = userStore.findByUserId(userId).orElse(null); + if (account != null) { + account.setTokenVersion(account.getTokenVersion() + 1); + userStore.save(account); + } + sessionStore.revokeByPrincipal(userId); + notifyRevoked(userId, null, reason); + logger.info("账号 {} 的全部会话已吊销:{}", userId, reason); + } + + /** 踢出单个会话。 */ + public void revokeSession(String sessionId, String reason) { + LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null); + if (session == null) { + throw AuthException.badRequest("会话不存在"); + } + session.setRevoked(true); + sessionStore.save(session); + notifyRevoked(session.getPrincipalId(), sessionId, reason); + logger.info("会话 {} 已被踢出:{}", sessionId, reason); + } + + public void ban(String userId, Instant until, String reason) { + UserAccount account = requireUser(userId); + account.setStatus(until == null ? AccountStatus.BANNED : AccountStatus.SUSPENDED); + account.setStatusUntil(until); + account.setStatusReason(reason); + userStore.save(account); + revokeAllSessions(userId, "账号已被封禁" + (reason == null ? "" : ":" + reason)); + logger.info("账号 {} 已被封禁(until={}, reason={})", userId, until, reason); + auditService.record("ADMIN", null, AuditService.ACTION_BAN, userId, + AuditService.RESULT_SUCCESS, null, + (until == null ? "永久封禁" : "临时封禁至 " + until) + ";原因:" + reason); + } + + public void unban(String userId) { + UserAccount account = requireUser(userId); + account.setStatus(AccountStatus.ACTIVE); + account.setStatusUntil(null); + account.setStatusReason(null); + account.setFailedAttempts(0); + account.setLockedUntil(null); + userStore.save(account); + logger.info("账号 {} 已解封", userId); + auditService.record("ADMIN", null, AuditService.ACTION_UNBAN, userId, + AuditService.RESULT_SUCCESS, null, "账号已解封"); + } + + /** + * 修改密码。 + * + *

校验原密码、密码强度,并禁止复用最近 + * {@value com.ttstd.signaling.model.UserAccount#PASSWORD_HISTORY_SIZE} 个密码。 + * 成功后吊销全部会话,强制重新登录。 + */ + public void changePassword(String userId, String oldPassword, String newPassword) { + UserAccount account = requireUser(userId); + if (!passwordEncoder.matches(oldPassword, account.getPasswordHash())) { + auditService.recordUser(userId, AuditService.ACTION_PASSWORD_CHANGED, + AuditService.RESULT_FAILURE, null, "原密码校验失败"); + throw AuthException.unauthorized("原密码不正确"); + } + validatePassword(newPassword, account.getUsername()); + + if (passwordEncoder.matches(newPassword, account.getPasswordHash())) { + throw AuthException.badRequest("新密码不能与当前密码相同"); + } + for (String historical : account.passwordHistoryList()) { + if (passwordEncoder.matches(newPassword, historical)) { + throw AuthException.badRequest("新密码不能与最近使用过的密码相同"); + } + } + + // 先把旧密码存入历史,再更新为新密码 + account.pushPasswordHistory(account.getPasswordHash()); + account.setPasswordHash(passwordEncoder.encode(newPassword)); + account.setPasswordChangedAt(Instant.now()); + userStore.save(account); + + revokeAllSessions(userId, "密码已修改,请重新登录"); + auditService.recordUser(userId, AuditService.ACTION_PASSWORD_CHANGED, + AuditService.RESULT_SUCCESS, null, "密码修改成功,已吊销全部会话"); + } + + // ==================== TOTP 双因子 ==================== + + /** + * 生成 TOTP 密钥并返回 otpauth URI。此时尚未启用,需调用 + * {@link #enableTotp(String, String)} 输入一次动态码完成绑定。 + */ + public Map setupTotp(String userId) { + UserAccount account = requireUser(userId); + if (account.isTotpEnabled()) { + throw AuthException.badRequest("双因子认证已启用,请先关闭再重新绑定"); + } + String secret = totpService.generateSecret(); + account.setTotpSecret(secret); + account.setTotpEnabled(false); + userStore.save(account); + + String uri = totpService.buildOtpAuthUri( + properties.getJwt().getIssuer(), account.getUsername(), secret); + return Map.of("secret", secret, "otpauthUri", uri); + } + + /** 校验一次动态码并正式启用 TOTP。 */ + public void enableTotp(String userId, String code) { + UserAccount account = requireUser(userId); + if (account.getTotpSecret() == null || account.getTotpSecret().isBlank()) { + throw AuthException.badRequest("请先调用 setup 生成密钥"); + } + if (account.isTotpEnabled()) { + throw AuthException.badRequest("双因子认证已启用"); + } + if (!totpService.verify(account.getTotpSecret(), code)) { + auditService.recordUser(userId, AuditService.ACTION_TOTP_FAILED, + AuditService.RESULT_FAILURE, null, "启用时动态码校验失败"); + throw AuthException.badRequest("动态码不正确"); + } + account.setTotpEnabled(true); + userStore.save(account); + auditService.recordUser(userId, AuditService.ACTION_TOTP_ENABLED, + AuditService.RESULT_SUCCESS, null, "双因子认证已启用"); + } + + /** 关闭 TOTP,需同时校验密码与动态码,防止令牌被盗后被单方面关闭。 */ + public void disableTotp(String userId, String password, String code) { + UserAccount account = requireUser(userId); + if (!account.isTotpEnabled()) { + throw AuthException.badRequest("双因子认证未启用"); + } + if (!passwordEncoder.matches(password, account.getPasswordHash())) { + throw AuthException.unauthorized("密码不正确"); + } + if (!totpService.verify(account.getTotpSecret(), code)) { + throw AuthException.badRequest("动态码不正确"); + } + account.setTotpEnabled(false); + account.setTotpSecret(null); + userStore.save(account); + auditService.recordUser(userId, AuditService.ACTION_TOTP_DISABLED, + AuditService.RESULT_SUCCESS, null, "双因子认证已关闭"); + } + + private void notifyRevoked(String principalId, String sessionId, String reason) { + SessionRevocationListener listener = this.revocationListener; + if (listener != null) { + try { + listener.onRevoked(principalId, sessionId, reason); + } catch (Exception e) { + logger.warn("下发会话失效通知失败: {}", e.getMessage()); + } + } + } + + // ==================== 查询 ==================== + + public UserAccount requireUser(String userId) { + return userStore.findByUserId(userId).orElseThrow( + () -> AuthException.badRequest("账号不存在")); + } + + public List listUsers() { + return userStore.listAll(); + } + + public List listSessions(String userId) { + return sessionStore.listByPrincipal(userId).stream() + .filter(LoginSession::isActive) + .sorted(Comparator.comparing(LoginSession::getCreatedAt).reversed()) + .toList(); + } + + /** 清理刷新令牌已过期或已吊销的会话记录(周期性维护)。 */ + public void evictExpiredSessions() { + Instant now = Instant.now(); + for (LoginSession s : sessionStore.listAll()) { + if (s.isRevoked() || s.getRefreshExpiresAt().isBefore(now)) { + sessionStore.delete(s.getSessionId()); + } + } + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AuditService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AuditService.java new file mode 100644 index 0000000..54d4564 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/AuditService.java @@ -0,0 +1,139 @@ +package com.ttstd.signaling.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 安全审计日志。 + * + *

记录登录、激活、封禁、踢线等安全敏感操作,用于事后追溯。 + * 当前为内存环形缓冲实现(保留最近 N 条),同时写入 SLF4J 便于落盘采集; + * 后续可替换为写入 {@code audit_log} 表。 + * + *

写入前会对敏感信息做脱敏,避免日志泄露凭据。 + */ +@Service +public class AuditService { + + private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT"); + private static final int MAX_ENTRIES = 5000; + + /** 审计动作常量 */ + public static final String ACTION_LOGIN = "LOGIN"; + public static final String ACTION_LOGIN_FAILED = "LOGIN_FAILED"; + public static final String ACTION_LOGOUT = "LOGOUT"; + public static final String ACTION_REGISTER = "REGISTER"; + public static final String ACTION_REFRESH = "REFRESH"; + public static final String ACTION_REFRESH_REUSE = "REFRESH_TOKEN_REUSE"; + public static final String ACTION_PASSWORD_CHANGED = "PASSWORD_CHANGED"; + public static final String ACTION_ACCOUNT_LOCKED = "ACCOUNT_LOCKED"; + public static final String ACTION_TOTP_ENABLED = "TOTP_ENABLED"; + public static final String ACTION_TOTP_DISABLED = "TOTP_DISABLED"; + public static final String ACTION_TOTP_FAILED = "TOTP_FAILED"; + public static final String ACTION_BAN = "BAN"; + public static final String ACTION_UNBAN = "UNBAN"; + public static final String ACTION_KICK = "KICK"; + public static final String ACTION_PROVISION = "DEVICE_PROVISION"; + public static final String ACTION_DEVICE_TOKEN = "DEVICE_TOKEN"; + public static final String ACTION_DEVICE_DISABLED = "DEVICE_DISABLED"; + public static final String ACTION_DEVICE_ENABLED = "DEVICE_ENABLED"; + public static final String ACTION_BIND = "DEVICE_BIND"; + public static final String ACTION_UNBIND = "DEVICE_UNBIND"; + public static final String ACTION_BLACKLIST_ADD = "DEVICE_BLACKLIST_ADD"; + public static final String ACTION_BLACKLIST_REMOVE = "DEVICE_BLACKLIST_REMOVE"; + public static final String ACTION_OFFER_BLOCKED = "OFFER_BLOCKED"; + public static final String ACTION_PAIRING_GENERATED = "PAIRING_GENERATED"; + public static final String ACTION_PAIRING_REDEEMED = "PAIRING_REDEEMED"; + + public static final String RESULT_SUCCESS = "SUCCESS"; + public static final String RESULT_FAILURE = "FAILURE"; + + /** + * 单条审计记录。 + */ + public record AuditEntry( + long id, + String actorType, + String actorId, + String action, + String targetId, + String result, + String ip, + String detail, + long timestamp) { + } + + private final Deque entries = new ConcurrentLinkedDeque<>(); + private final AtomicLong sequence = new AtomicLong(); + + public void record(String actorType, String actorId, String action, + String targetId, String result, String ip, String detail) { + AuditEntry entry = new AuditEntry( + sequence.incrementAndGet(), + actorType, actorId, action, targetId, result, ip, + truncate(detail), + Instant.now().toEpochMilli()); + + entries.addFirst(entry); + // 环形缓冲:超出上限时丢弃最旧记录 + while (entries.size() > MAX_ENTRIES) { + entries.pollLast(); + } + + auditLogger.info("actor={}:{} action={} target={} result={} ip={} detail={}", + actorType, actorId, action, targetId, result, ip, entry.detail()); + } + + public void recordUser(String userId, String action, String result, String ip, String detail) { + record("USER", userId, action, null, result, ip, detail); + } + + public void recordDevice(String deviceUid, String action, String result, String ip, String detail) { + record("DEVICE", deviceUid, action, null, result, ip, detail); + } + + public void recordAdmin(String adminId, String action, String targetId, String detail) { + record("ADMIN", adminId, action, targetId, RESULT_SUCCESS, null, detail); + } + + /** 查询最近的审计记录。 */ + public List recent(int limit) { + List result = new ArrayList<>(Math.min(limit, entries.size())); + for (AuditEntry entry : entries) { + if (result.size() >= limit) { + break; + } + result.add(entry); + } + return result; + } + + /** 按主体过滤审计记录。 */ + public List byActor(String actorId, int limit) { + List result = new ArrayList<>(); + for (AuditEntry entry : entries) { + if (result.size() >= limit) { + break; + } + if (actorId.equals(entry.actorId())) { + result.add(entry); + } + } + return result; + } + + private static String truncate(String detail) { + if (detail == null) { + return null; + } + return detail.length() > 512 ? detail.substring(0, 512) : detail; + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/BindingService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/BindingService.java new file mode 100644 index 0000000..02bf1d8 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/BindingService.java @@ -0,0 +1,159 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.model.DeviceBinding; +import com.ttstd.signaling.model.DeviceBlacklistEntry; +import com.ttstd.signaling.repository.UserAccountRepository; +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.security.TokenUtils; +import com.ttstd.signaling.store.BindingStore; +import com.ttstd.signaling.store.BlacklistStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 绑定关系与黑名单服务。 + * + *

职责: + *

    + *
  • 维护主控端账号与被控端设备的绑定关系({@link DeviceBinding}); + *
  • 维护被控端黑名单({@link DeviceBlacklistEntry}); + *
  • 为信令链路提供 是否允许连接 的判定:仅 ACTIVE 绑定且未被拉黑的主控端可发起 OFFER; + *
  • 承载被控端自助管理(拉黑/解绑)与主控端自助查询。 + *
+ * + *

判定优先级:黑名单 > 绑定。即即便存在绑定,被拉黑后 OFFER 仍被服务端拒绝。 + */ +@Service +public class BindingService { + + private static final Logger logger = LoggerFactory.getLogger(BindingService.class); + + private final BindingStore bindingStore; + private final BlacklistStore blacklistStore; + private final UserAccountRepository userAccountRepository; + private final AuditService auditService; + + public BindingService(BindingStore bindingStore, BlacklistStore blacklistStore, + UserAccountRepository userAccountRepository, AuditService auditService) { + this.bindingStore = bindingStore; + this.blacklistStore = blacklistStore; + this.userAccountRepository = userAccountRepository; + this.auditService = auditService; + } + + // ==================== 绑定 ==================== + + /** + * 创建或恢复绑定关系。若已存在(含 REVOKED)则复用并更新为 ACTIVE。 + * + * @return 绑定记录 + */ + public DeviceBinding bind(String deviceUid, String userId, DeviceBinding.BindingRole role, + String alias, String boundBy) { + DeviceBinding existing = bindingStore.findByDeviceUidAndUserId(deviceUid, userId).orElse(null); + if (existing != null) { + existing.setStatus(DeviceBinding.BindingStatus.ACTIVE); + existing.setRole(role); + if (alias != null) { + existing.setAlias(alias); + } + existing.setBoundBy(boundBy); + DeviceBinding saved = bindingStore.save(existing); + logger.info("绑定恢复/更新: device={} user={} role={}", deviceUid, userId, role); + auditService.recordDevice(deviceUid, AuditService.ACTION_BIND, AuditService.RESULT_SUCCESS, + null, "user=" + userId + " role=" + role); + return saved; + } + DeviceBinding binding = new DeviceBinding( + "bind_" + TokenUtils.randomBase62(20), deviceUid, userId, role, alias, boundBy); + DeviceBinding saved = bindingStore.save(binding); + logger.info("绑定建立: device={} user={} binding={} role={}", deviceUid, userId, + saved.getBindingId(), role); + auditService.recordDevice(deviceUid, AuditService.ACTION_BIND, AuditService.RESULT_SUCCESS, + null, "user=" + userId + " role=" + role); + return saved; + } + + /** 解绑(软删除:置为 REVOKED)。 */ + public void revokeBinding(String deviceUid, String userId, String actor) { + DeviceBinding binding = bindingStore.findByDeviceUidAndUserId(deviceUid, userId).orElse(null); + if (binding == null) { + return; + } + binding.setStatus(DeviceBinding.BindingStatus.REVOKED); + bindingStore.save(binding); + logger.info("解绑: device={} user={} by={}", deviceUid, userId, actor); + auditService.recordDevice(deviceUid, AuditService.ACTION_UNBIND, AuditService.RESULT_SUCCESS, + null, "user=" + userId + " by=" + actor); + } + + /** 主控端是否对该设备拥有 ACTIVE 绑定。 */ + public boolean isBound(String deviceUid, String userId) { + return bindingStore.findByDeviceUidAndUserId(deviceUid, userId) + .map(DeviceBinding::isActive) + .orElse(false); + } + + public List listByUser(String userId) { + return bindingStore.listByUserId(userId); + } + + public List listByDevice(String deviceUid) { + return bindingStore.listByDeviceUid(deviceUid); + } + + // ==================== 黑名单 ==================== + + /** 被控端拉黑某主控端账号。 */ + public DeviceBlacklistEntry addBlacklist(String deviceUid, String blockedUserId, String reason, + String actor) { + if (blacklistStore.existsByDeviceUidAndBlockedUserId(deviceUid, blockedUserId)) { + DeviceBlacklistEntry existing = blacklistStore + .findByDeviceUidAndBlockedUserId(deviceUid, blockedUserId).orElseThrow(); + existing.setReason(reason); + return blacklistStore.save(existing); + } + DeviceBlacklistEntry entry = new DeviceBlacklistEntry(deviceUid, blockedUserId, reason); + DeviceBlacklistEntry saved = blacklistStore.save(entry); + logger.info("拉黑: device={} blockedUser={} by={}", deviceUid, blockedUserId, actor); + auditService.recordDevice(deviceUid, AuditService.ACTION_BLACKLIST_ADD, AuditService.RESULT_SUCCESS, + null, "blockedUser=" + blockedUserId + " by=" + actor); + return saved; + } + + public void removeBlacklist(String deviceUid, String blockedUserId, String actor) { + if (!blacklistStore.existsByDeviceUidAndBlockedUserId(deviceUid, blockedUserId)) { + return; + } + blacklistStore.delete(deviceUid, blockedUserId); + logger.info("移除黑名单: device={} blockedUser={} by={}", deviceUid, blockedUserId, actor); + auditService.recordDevice(deviceUid, AuditService.ACTION_BLACKLIST_REMOVE, + AuditService.RESULT_SUCCESS, null, "blockedUser=" + blockedUserId + " by=" + actor); + } + + /** 该主控端是否被设备拉黑。 */ + public boolean isBlacklisted(String deviceUid, String userId) { + return blacklistStore.existsByDeviceUidAndBlockedUserId(deviceUid, userId); + } + + public List listBlacklist(String deviceUid) { + return blacklistStore.listByDeviceUid(deviceUid); + } + + // ==================== 用户名解析(被控端按用户名拉黑/绑定) ==================== + + /** + * 将用户名解析为用户 ID。失败抛出 {@link AuthException#badRequest}。 + */ + public String resolveUserId(String username) { + if (username == null || username.isBlank()) { + throw AuthException.badRequest("用户名不能为空"); + } + return userAccountRepository.findByUsername(username.trim()) + .map(u -> u.getUserId()) + .orElseThrow(() -> AuthException.badRequest("账号不存在: " + username)); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/DeviceIdentityService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/DeviceIdentityService.java new file mode 100644 index 0000000..0524e40 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/DeviceIdentityService.java @@ -0,0 +1,281 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.model.AccountStatus; +import com.ttstd.signaling.model.AuthPrincipal; +import com.ttstd.signaling.model.DeviceAccount; +import com.ttstd.signaling.model.DeviceAllowlist; +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.TokenUtils; +import com.ttstd.signaling.store.DeviceStore; +import com.ttstd.signaling.store.NonceStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 被控端设备身份服务。 + * + *

被控端为系统签名应用、无法登录账号,因此采用「SN + 内置共享密钥 HMAC」完成首次激活, + * 激活后换取长期 {@code deviceSecret},再由 deviceSecret 换取短期访问令牌。 + * + *

底层存储通过 {@link DeviceStore}/{@link NonceStore} 抽象,生产环境使用 MySQL/JPA + Redis, + * 测试/演示使用内存实现,调用方无感知。 + */ +@Service +public class DeviceIdentityService { + + private static final Logger logger = LoggerFactory.getLogger(DeviceIdentityService.class); + + private final DeviceStore deviceStore; + private final NonceStore nonceStore; + private final SecurityProperties properties; + private final JwtService jwtService; + + private volatile AccountService.SessionRevocationListener revocationListener; + + public DeviceIdentityService(DeviceStore deviceStore, + NonceStore nonceStore, + SecurityProperties properties, + JwtService jwtService) { + this.deviceStore = deviceStore; + this.nonceStore = nonceStore; + this.properties = properties; + this.jwtService = jwtService; + } + + public void setRevocationListener(AccountService.SessionRevocationListener listener) { + this.revocationListener = listener; + } + + /** + * 设备激活结果。deviceSecret 仅此一次返回明文。 + */ + public record ProvisionResult(String deviceUid, String deviceSecret) { + } + + // ==================== 激活 ==================== + + /** + * 首次激活:校验 SN 白名单、HMAC 签名、时间戳与 nonce,签发 deviceSecret。 + * + *

若设备已激活,则执行重新激活(轮换 deviceSecret 并吊销旧令牌)。 + */ + public ProvisionResult provision(String sn, String model, String nonce, long timestamp, String hmac) { + if (sn == null || sn.isBlank()) { + throw AuthException.badRequest("SN 不能为空"); + } + String secret = properties.getDevice().getProvisionSecret(); + if (secret == null || secret.isBlank()) { + logger.error("未配置 security.device.provision-secret,拒绝所有设备激活请求"); + throw AuthException.forbidden("设备激活未启用", "provision secret not configured"); + } + if (nonce == null || nonce.isBlank() || hmac == null || hmac.isBlank()) { + throw AuthException.badRequest("激活参数不完整"); + } + + long skew = properties.getDevice().getProvisionSkewSeconds(); + long now = Instant.now().getEpochSecond(); + if (Math.abs(now - timestamp) > skew) { + throw AuthException.forbidden("激活请求已过期", "provision timestamp out of window, sn=" + + DeviceAccount.maskSn(sn)); + } + + // nonce 唯一性校验(防重放) + if (!nonceStore.tryReserve(nonce, skew * 2)) { + throw AuthException.forbidden("激活请求重复", "nonce replayed, sn=" + DeviceAccount.maskSn(sn)); + } + + String expected = hmacHex(secret, sn + "|" + nonce + "|" + timestamp); + if (!MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + hmac.toLowerCase().getBytes(StandardCharsets.UTF_8))) { + throw AuthException.forbidden("激活签名校验失败", + "provision hmac mismatch, sn=" + DeviceAccount.maskSn(sn)); + } + + if (properties.getDevice().isSnAllowlistEnabled() && !deviceStore.isSnAllowed(sn)) { + throw AuthException.forbidden("设备未授权", + "sn not in allowlist: " + DeviceAccount.maskSn(sn)); + } + + String deviceSecret = TokenUtils.randomSecret(32); + String secretHash = TokenUtils.sha256(deviceSecret); + + DeviceAccount existing = deviceStore.findBySn(sn).orElse(null); + if (existing != null) { + if (existing.getStatus() == AccountStatus.BANNED) { + throw AuthException.forbidden("设备已被禁用", + "banned device re-provision attempt: " + existing.maskedSn()); + } + existing.setSecretHash(secretHash); + existing.setModel(model); + existing.setTokenVersion(existing.getTokenVersion() + 1); + deviceStore.save(existing); + notifyRevoked(existing.getDeviceUid(), "设备已重新激活,旧凭据失效"); + logger.warn("设备重新激活: uid={} sn={}", existing.getDeviceUid(), existing.maskedSn()); + return new ProvisionResult(existing.getDeviceUid(), deviceSecret); + } + + String deviceUid = "dev_" + TokenUtils.randomBase62(22); + DeviceAccount device = new DeviceAccount(deviceUid, sn, secretHash, model); + deviceStore.save(device); + logger.info("设备已激活: uid={} sn={} model={}", deviceUid, device.maskedSn(), model); + return new ProvisionResult(deviceUid, deviceSecret); + } + + // ==================== 换取令牌 ==================== + + public TokenPair issueDeviceToken(String deviceUid, String deviceSecret) { + DeviceAccount device = deviceUid == null ? null : deviceStore.findByDeviceUid(deviceUid).orElse(null); + if (device == null || deviceSecret == null) { + throw AuthException.unauthorized("设备凭据无效"); + } + if (!TokenUtils.constantTimeEquals(device.getSecretHash(), TokenUtils.sha256(deviceSecret))) { + throw AuthException.unauthorized("设备凭据无效: " + deviceUid); + } + assertDeviceUsable(device); + + Map claims = jwtService.newClaims(); + claims.put("typ", PrincipalType.DEVICE.name()); + claims.put("model", device.getModel()); + String token = jwtService.issue(device.getDeviceUid(), JwtService.PURPOSE_DEVICE, + properties.getJwt().getDeviceTokenTtlSeconds(), + device.getTokenVersion(), claims); + + device.setLastOnlineAt(Instant.now()); + deviceStore.save(device); + return new TokenPair(token, null, + properties.getJwt().getDeviceTokenTtlSeconds(), + null, device.getDeviceUid(), device.getModel()); + } + + public AuthPrincipal authenticate(String token) { + Map claims = jwtService.verify(token, JwtService.PURPOSE_DEVICE); + if (!PrincipalType.DEVICE.name().equals(JwtService.claimAsString(claims, "typ"))) { + throw AuthException.unauthorized("令牌主体类型不匹配"); + } + String deviceUid = JwtService.claimAsString(claims, "sub"); + DeviceAccount device = deviceStore.findByDeviceUid(deviceUid).orElse(null); + if (device == null) { + throw AuthException.unauthorized("设备不存在: " + deviceUid); + } + long ver = JwtService.claimAsLong(claims, "ver", -1); + if (ver != device.getTokenVersion()) { + throw AuthException.unauthorized("设备令牌已失效(凭据版本变更)"); + } + assertDeviceUsable(device); + device.setLastOnlineAt(Instant.now()); + deviceStore.save(device); + + return new AuthPrincipal(PrincipalType.DEVICE, deviceUid, deviceUid, + DeviceType.CONTROLLED, null, device.getModel(), false); + } + + private void assertDeviceUsable(DeviceAccount device) { + if (device.getStatus() == AccountStatus.SUSPENDED + && device.getStatusUntil() != null + && Instant.now().isAfter(device.getStatusUntil())) { + device.setStatus(AccountStatus.ACTIVE); + device.setStatusUntil(null); + device.setStatusReason(null); + deviceStore.save(device); + } + if (!device.isUsable()) { + throw AuthException.forbidden("设备已被禁用", + "device not usable: " + device.getDeviceUid()); + } + } + + // ==================== 管理 ==================== + + public void disable(String deviceUid, Instant until, String reason) { + DeviceAccount device = requireDevice(deviceUid); + device.setStatus(until == null ? AccountStatus.BANNED : AccountStatus.SUSPENDED); + device.setStatusUntil(until); + device.setStatusReason(reason); + device.setTokenVersion(device.getTokenVersion() + 1); + deviceStore.save(device); + notifyRevoked(deviceUid, "设备已被禁用" + (reason == null ? "" : ":" + reason)); + logger.info("设备 {} 已被禁用(until={}, reason={})", deviceUid, until, reason); + } + + public void enable(String deviceUid) { + DeviceAccount device = requireDevice(deviceUid); + device.setStatus(AccountStatus.ACTIVE); + device.setStatusUntil(null); + device.setStatusReason(null); + deviceStore.save(device); + logger.info("设备 {} 已启用", deviceUid); + } + + public DeviceAccount requireDevice(String deviceUid) { + return deviceStore.findByDeviceUid(deviceUid) + .orElseThrow(() -> AuthException.badRequest("设备不存在")); + } + + public DeviceAccount findByUid(String deviceUid) { + return deviceUid == null ? null : deviceStore.findByDeviceUid(deviceUid).orElse(null); + } + + public List listDevices() { + return new ArrayList<>(deviceStore.listAll()); + } + + /** 批量导入 SN 白名单(持久化到 MySQL)。 */ + public int importAllowlist(List sns) { + int added = 0; + String importedBy = "admin"; + for (String sn : sns) { + if (sn == null || sn.isBlank()) { + continue; + } + String trimmed = sn.trim(); + if (!deviceStore.isSnAllowed(trimmed)) { + deviceStore.addAllowedSn(new DeviceAllowlist(trimmed, "manual", importedBy)); + added++; + } + } + logger.info("SN 白名单导入 {} 条,当前总数 {}", added, deviceStore.listAllowed().size()); + return added; + } + + public Set getAllowlist() { + return Set.copyOf(deviceStore.listAllowed().stream().map(DeviceAllowlist::getSn).toList()); + } + + private void notifyRevoked(String deviceUid, String reason) { + AccountService.SessionRevocationListener listener = this.revocationListener; + if (listener != null) { + try { + listener.onRevoked(deviceUid, null, reason); + } catch (Exception e) { + logger.warn("下发设备失效通知失败: {}", e.getMessage()); + } + } + } + + 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("HMAC 计算失败", e); + } + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/PairingService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/PairingService.java new file mode 100644 index 0000000..b68a429 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/PairingService.java @@ -0,0 +1,110 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.model.DeviceBinding; +import com.ttstd.signaling.model.PairingCode; +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.security.TokenUtils; +import com.ttstd.signaling.store.PairingStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +/** + * 配对码服务:建立「被控端 → 主控端」绑定的用户友好入口。 + * + *

被控端调用 {@link #generate(String)} 生成一次性配对码(明文仅回显一次,存 SHA-256 摘要); + * 主控端输入配对码调用 {@link #redeem(String, String)},服务端校验后创建绑定关系。 + * + *

防护:配对码有时效(默认 10 分钟)、单次使用、错误尝试上限(默认 5 次,超限即失效), + * 且摘要存储,避免明文泄露与离线爆破。 + */ +@Service +public class PairingService { + + private static final Logger logger = LoggerFactory.getLogger(PairingService.class); + + private static final int MAX_ATTEMPTS = 5; + private static final int CODE_LENGTH = 8; + + private final PairingStore pairingStore; + private final BindingService bindingService; + private final AuditService auditService; + private final long ttlSeconds; + + public PairingService(PairingStore pairingStore, + BindingService bindingService, + AuditService auditService, + @Value("${security.pairing.ttl-seconds:600}") long ttlSeconds) { + this.pairingStore = pairingStore; + this.bindingService = bindingService; + this.auditService = auditService; + this.ttlSeconds = ttlSeconds; + } + + /** 生成配对码,返回明文(仅此一次)。 */ + public String generate(String deviceUid) { + if (deviceUid == null || deviceUid.isBlank()) { + throw AuthException.badRequest("设备标识不能为空"); + } + // 去除易混淆字符(0/O/1/I/l),仅保留 base32 风格字符 + String code = randomReadableCode(CODE_LENGTH); + String hash = TokenUtils.sha256(code); + Instant expiresAt = Instant.now().plusSeconds(ttlSeconds); + PairingCode pc = new PairingCode(hash, deviceUid, expiresAt); + pairingStore.save(pc); + logger.info("生成配对码: device={} ttl={}s", deviceUid, ttlSeconds); + auditService.recordDevice(deviceUid, AuditService.ACTION_PAIRING_GENERATED, + AuditService.RESULT_SUCCESS, null, "ttl=" + ttlSeconds); + return code; + } + + /** + * 兑换配对码:校验通过后创建绑定。返回绑定记录。 + */ + public DeviceBinding redeem(String code, String userId) { + if (code == null || code.isBlank() || userId == null) { + throw AuthException.badRequest("配对码不能为空"); + } + String normalized = code.trim().toUpperCase(); + String hash = TokenUtils.sha256(normalized); + PairingCode pc = pairingStore.findByCodeHash(hash) + .orElseThrow(() -> AuthException.badRequest("配对码无效")); + if (pc.isUsed()) { + throw AuthException.badRequest("配对码已被使用"); + } + if (pc.isExpired()) { + pairingStore.delete(hash); + throw AuthException.badRequest("配对码已过期"); + } + if (pc.getAttempts() >= MAX_ATTEMPTS) { + pairingStore.delete(hash); + throw AuthException.badRequest("配对码尝试次数过多,已失效"); + } + // 校验通过:标记已用并创建绑定(主控端 MEMBER 角色) + pc.setUsed(true); + pairingStore.save(pc); + DeviceBinding binding = bindingService.bind( + pc.getDeviceUid(), userId, DeviceBinding.BindingRole.MEMBER, null, + "pairing:" + pc.getDeviceUid()); + logger.info("配对码兑换成功: device={} user={} binding={}", pc.getDeviceUid(), userId, + binding.getBindingId()); + auditService.recordUser(userId, AuditService.ACTION_PAIRING_REDEEMED, + AuditService.RESULT_SUCCESS, null, "device=" + pc.getDeviceUid()); + return binding; + } + + private static final java.security.SecureRandom RND = new java.security.SecureRandom(); + + private static String randomReadableCode(int length) { + // base32 字符集(去除了易混淆的 0/O/1/I/L) + final String alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(alphabet.charAt(RND.nextInt(alphabet.length()))); + } + return sb.toString(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/SecurityMaintenanceTask.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/SecurityMaintenanceTask.java new file mode 100644 index 0000000..ea0502d --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/SecurityMaintenanceTask.java @@ -0,0 +1,31 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.security.RateLimiter; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 安全相关内存结构的周期性清理,防止长期运行造成内存增长。 + */ +@Component +public class SecurityMaintenanceTask { + + private final AccountService accountService; + private final DeviceIdentityService deviceIdentityService; + private final RateLimiter rateLimiter; + + public SecurityMaintenanceTask(AccountService accountService, + DeviceIdentityService deviceIdentityService, + RateLimiter rateLimiter) { + this.accountService = accountService; + this.deviceIdentityService = deviceIdentityService; + this.rateLimiter = rateLimiter; + } + + /** 每 5 分钟清理一次过期会话与限流窗口(nonce 由 Redis 自动过期)。 */ + @Scheduled(fixedDelay = 300_000L, initialDelay = 300_000L) + public void cleanup() { + accountService.evictExpiredSessions(); + rateLimiter.evictExpired(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/TokenPair.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/TokenPair.java new file mode 100644 index 0000000..02e4a7c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/TokenPair.java @@ -0,0 +1,20 @@ +package com.ttstd.signaling.service; + +/** + * 登录/刷新返回的令牌对。 + * + * @param accessToken 短期访问令牌(用于 HTTP 接口与 WebSocket 握手) + * @param refreshToken 刷新令牌(一次性,使用后轮转) + * @param expiresInSeconds 访问令牌剩余有效期 + * @param sessionId 登录会话 ID + * @param principalId 主体 ID + * @param displayName 展示名 + */ +public record TokenPair( + String accessToken, + String refreshToken, + long expiresInSeconds, + String sessionId, + String principalId, + String displayName) { +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/TurnCredentialService.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/TurnCredentialService.java new file mode 100644 index 0000000..c218436 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/service/TurnCredentialService.java @@ -0,0 +1,111 @@ +package com.ttstd.signaling.service; + +import com.ttstd.signaling.security.AuthException; +import com.ttstd.signaling.security.SecurityProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * TURN 短期凭证服务(RFC 7635 风格)。 + * + *

客户端在发起/接受连接前,向本服务换取一组有效期有限的 TURN 凭证: + *

    + *
  • {@code username} 形如 {@code :},过期时间编码其中;
  • + *
  • {@code credential} 为 {@code HMAC-SHA1(sharedSecret, username)} 的 Base64;
  • + *
  • 凭证到期需重新获取,避免长时间有效的静态密钥泄露风险。
  • + *
+ * + *

返回的 {@code iceServers} 可直接用于 WebRTC 的 {@code RTCPeerConnection} 配置。 + */ +@Service +public class TurnCredentialService { + + private static final Logger logger = LoggerFactory.getLogger(TurnCredentialService.class); + private static final Base64.Encoder B64 = Base64.getEncoder(); + + private final SecurityProperties properties; + + public TurnCredentialService(SecurityProperties properties) { + this.properties = properties; + } + + /** 是否已配置并可发放 TURN 凭证。 */ + public boolean isEnabled() { + return properties.getTurn().isEnabled() + && properties.getTurn().getSharedSecret() != null + && !properties.getTurn().getSharedSecret().isBlank() + && properties.getTurn().getUrls() != null + && !properties.getTurn().getUrls().isBlank(); + } + + /** + * 生成一组 TURN 短期凭证。 + * + * @param scope 用途提示(如 "controller:" + bindingId),仅用于审计/日志,不影响计算 + * @return 包含 iceServers 的响应体 + */ + public Map issue(String scope) { + if (!isEnabled()) { + throw AuthException.unauthorized("TURN 凭证服务未启用"); + } + SecurityProperties.Turn turn = properties.getTurn(); + long ttl = turn.getTtlSeconds(); + long expSeconds = Instant.now().getEpochSecond() + ttl; + String username = expSeconds + ":" + randomToken(12); + String credential = hmacSha1(turn.getSharedSecret(), username); + + List> iceServers = new ArrayList<>(); + for (String rawUrl : turn.getUrls().split(",")) { + String url = rawUrl.trim(); + if (url.isEmpty()) { + continue; + } + Map server = new LinkedHashMap<>(); + server.put("urls", url); + server.put("username", username); + server.put("credential", credential); + iceServers.add(server); + } + + Map response = new LinkedHashMap<>(); + response.put("iceServers", iceServers); + response.put("expiresAt", expSeconds); + response.put("ttlSeconds", ttl); + logger.debug("发放 TURN 凭证: scope={} exp={}", scope, expSeconds); + return response; + } + + private static String hmacSha1(String key, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return B64.encodeToString(raw); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("TURN 凭证计算失败", e); + } + } + + private static String randomToken(int length) { + java.security.SecureRandom rnd = new java.security.SecureRandom(); + final String alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(alphabet.charAt(rnd.nextInt(alphabet.length()))); + } + return sb.toString(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/BindingStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/BindingStore.java new file mode 100644 index 0000000..67fe8a1 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/BindingStore.java @@ -0,0 +1,22 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceBinding; + +import java.util.List; +import java.util.Optional; + +/** + * 绑定关系存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。 + */ +public interface BindingStore { + + DeviceBinding save(DeviceBinding binding); + + Optional findByDeviceUidAndUserId(String deviceUid, String userId); + + List listByDeviceUid(String deviceUid); + + List listByUserId(String userId); + + void delete(String bindingId); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/BlacklistStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/BlacklistStore.java new file mode 100644 index 0000000..de02d09 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/BlacklistStore.java @@ -0,0 +1,22 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceBlacklistEntry; + +import java.util.List; +import java.util.Optional; + +/** + * 被控端黑名单存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。 + */ +public interface BlacklistStore { + + DeviceBlacklistEntry save(DeviceBlacklistEntry entry); + + Optional findByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId); + + boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId); + + List listByDeviceUid(String deviceUid); + + void delete(String deviceUid, String blockedUserId); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/DeviceStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/DeviceStore.java new file mode 100644 index 0000000..305d648 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/DeviceStore.java @@ -0,0 +1,22 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceAccount; +import com.ttstd.signaling.model.DeviceAllowlist; + +import java.util.List; +import java.util.Optional; + +/** + * 设备身份存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。 + */ +public interface DeviceStore { + Optional findByDeviceUid(String deviceUid); + Optional findBySn(String sn); + boolean existsBySn(String sn); + DeviceAccount save(DeviceAccount account); + List listAll(); + + boolean isSnAllowed(String sn); + void addAllowedSn(DeviceAllowlist entry); + List listAllowed(); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryBindingStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryBindingStore.java new file mode 100644 index 0000000..35c2931 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryBindingStore.java @@ -0,0 +1,51 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceBinding; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** 内存实现:不依赖数据库,用于测试与本地演示(storage.mode=memory)。 */ +@Component +@Profile("memory") +public class InMemoryBindingStore implements BindingStore { + + private final ConcurrentMap byId = new ConcurrentHashMap<>(); + + @Override + public synchronized DeviceBinding save(DeviceBinding binding) { + byId.put(binding.getBindingId(), binding); + return binding; + } + + @Override + public synchronized Optional findByDeviceUidAndUserId(String deviceUid, String userId) { + return byId.values().stream() + .filter(b -> b.getDeviceUid().equals(deviceUid) && b.getUserId().equals(userId)) + .findFirst(); + } + + @Override + public synchronized List listByDeviceUid(String deviceUid) { + return byId.values().stream() + .filter(b -> b.getDeviceUid().equals(deviceUid)) + .collect(Collectors.toList()); + } + + @Override + public synchronized List listByUserId(String userId) { + return byId.values().stream() + .filter(b -> b.getUserId().equals(userId)) + .collect(Collectors.toList()); + } + + @Override + public synchronized void delete(String bindingId) { + byId.remove(bindingId); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryBlacklistStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryBlacklistStore.java new file mode 100644 index 0000000..34119e0 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryBlacklistStore.java @@ -0,0 +1,52 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceBlacklistEntry; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** 内存实现:不依赖数据库,用于测试与本地演示(storage.mode=memory)。 */ +@Component +@Profile("memory") +public class InMemoryBlacklistStore implements BlacklistStore { + + private final ConcurrentMap entries = new ConcurrentHashMap<>(); + + private static String key(String deviceUid, String blockedUserId) { + return deviceUid + "\u0000" + blockedUserId; + } + + @Override + public synchronized DeviceBlacklistEntry save(DeviceBlacklistEntry entry) { + entries.put(key(entry.getDeviceUid(), entry.getBlockedUserId()), entry); + return entry; + } + + @Override + public synchronized Optional findByDeviceUidAndBlockedUserId( + String deviceUid, String blockedUserId) { + return Optional.ofNullable(entries.get(key(deviceUid, blockedUserId))); + } + + @Override + public synchronized boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId) { + return entries.containsKey(key(deviceUid, blockedUserId)); + } + + @Override + public synchronized List listByDeviceUid(String deviceUid) { + return entries.values().stream() + .filter(e -> e.getDeviceUid().equals(deviceUid)) + .collect(Collectors.toList()); + } + + @Override + public synchronized void delete(String deviceUid, String blockedUserId) { + entries.remove(key(deviceUid, blockedUserId)); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryDeviceStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryDeviceStore.java new file mode 100644 index 0000000..c571164 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryDeviceStore.java @@ -0,0 +1,65 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceAccount; +import com.ttstd.signaling.model.DeviceAllowlist; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** 内存实现:不依赖数据库,用于测试与本地演示(storage.mode=memory)。 */ +@Component +@Profile("memory") +public class InMemoryDeviceStore implements DeviceStore { + + private final ConcurrentMap byUid = new ConcurrentHashMap<>(); + private final ConcurrentMap snToUid = new ConcurrentHashMap<>(); + private final ConcurrentMap allowlist = new ConcurrentHashMap<>(); + + @Override + public synchronized Optional findByDeviceUid(String deviceUid) { + return Optional.ofNullable(byUid.get(deviceUid)); + } + + @Override + public synchronized Optional findBySn(String sn) { + String uid = snToUid.get(sn); + return uid == null ? Optional.empty() : Optional.ofNullable(byUid.get(uid)); + } + + @Override + public synchronized boolean existsBySn(String sn) { + return snToUid.containsKey(sn); + } + + @Override + public synchronized DeviceAccount save(DeviceAccount account) { + byUid.put(account.getDeviceUid(), account); + snToUid.put(account.getSn(), account.getDeviceUid()); + return account; + } + + @Override + public synchronized List listAll() { + return List.copyOf(byUid.values()); + } + + @Override + public synchronized boolean isSnAllowed(String sn) { + return allowlist.containsKey(sn); + } + + @Override + public synchronized void addAllowedSn(DeviceAllowlist entry) { + allowlist.put(entry.getSn(), entry); + } + + @Override + public synchronized List listAllowed() { + return List.copyOf(allowlist.values()); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryNonceStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryNonceStore.java new file mode 100644 index 0000000..21dfd35 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryNonceStore.java @@ -0,0 +1,27 @@ +package com.ttstd.signaling.store; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** 内存 nonce 去重实现,用于 storage.mode=memory 或 Redis 不可用时的降级。 */ +@Component +@Profile("memory") +public class InMemoryNonceStore implements NonceStore { + + private final Map nonces = new ConcurrentHashMap<>(); + + @Override + public synchronized boolean tryReserve(String nonce, long ttlSeconds) { + Instant now = Instant.now(); + nonces.entrySet().removeIf(e -> now.isAfter(e.getValue())); + if (nonces.containsKey(nonce)) { + return false; + } + nonces.put(nonce, now.plusSeconds(ttlSeconds)); + return true; + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryPairingStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryPairingStore.java new file mode 100644 index 0000000..fe94413 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryPairingStore.java @@ -0,0 +1,33 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.PairingCode; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** 内存实现,用于测试与本地演示(storage.mode=memory)。 */ +@Component +@Profile("memory") +public class InMemoryPairingStore implements PairingStore { + + private final ConcurrentMap byHash = new ConcurrentHashMap<>(); + + @Override + public synchronized PairingCode save(PairingCode code) { + byHash.put(code.getCodeHash(), code); + return code; + } + + @Override + public synchronized Optional findByCodeHash(String codeHash) { + return Optional.ofNullable(byHash.get(codeHash)); + } + + @Override + public synchronized void delete(String codeHash) { + byHash.remove(codeHash); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemorySessionStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemorySessionStore.java new file mode 100644 index 0000000..0a0c8ba --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemorySessionStore.java @@ -0,0 +1,89 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.LoginSession; +import com.ttstd.signaling.model.PrincipalType; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** 内存实现:不依赖数据库,用于测试与本地演示(storage.mode=memory)。 */ +@Component +@Profile("memory") +public class InMemorySessionStore implements SessionStore { + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + + @Override + public synchronized LoginSession save(LoginSession session) { + sessions.put(session.getSessionId(), session); + return session; + } + + @Override + public synchronized Optional findBySessionId(String sessionId) { + return Optional.ofNullable(sessions.get(sessionId)); + } + + @Override + public synchronized List listByPrincipal(String principalId) { + return sessions.values().stream() + .filter(s -> s.getPrincipalId().equals(principalId)) + .collect(Collectors.toList()); + } + + @Override + public synchronized List listByPrincipalAndType(String principalId, PrincipalType type) { + return sessions.values().stream() + .filter(s -> s.getPrincipalId().equals(principalId) && s.getPrincipalType() == type) + .collect(Collectors.toList()); + } + + @Override + public synchronized List listAll() { + return List.copyOf(sessions.values()); + } + + @Override + public synchronized void delete(String sessionId) { + sessions.remove(sessionId); + } + + @Override + public synchronized void deleteByPrincipal(String principalId) { + sessions.values().removeIf(s -> s.getPrincipalId().equals(principalId)); + } + + @Override + public synchronized int revokeByPrincipal(String principalId) { + int n = 0; + for (LoginSession s : sessions.values()) { + if (s.getPrincipalId().equals(principalId)) { + s.setRevoked(true); + n++; + } + } + return n; + } + + @Override + public synchronized int revokeBySessionId(String sessionId) { + LoginSession s = sessions.get(sessionId); + if (s != null) { + s.setRevoked(true); + return 1; + } + return 0; + } + + @Override + public synchronized long countByPrincipalAndType(String principalId, PrincipalType type) { + return sessions.values().stream() + .filter(s -> s.getPrincipalId().equals(principalId) && s.getPrincipalType() == type) + .count(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryUserStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryUserStore.java new file mode 100644 index 0000000..8be488a --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/InMemoryUserStore.java @@ -0,0 +1,48 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.UserAccount; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** 内存实现:不依赖数据库,用于测试与本地演示(storage.mode=memory)。 */ +@Component +@Profile("memory") +public class InMemoryUserStore implements UserStore { + + private final ConcurrentMap byId = new ConcurrentHashMap<>(); + private final ConcurrentMap usernameToId = new ConcurrentHashMap<>(); + + @Override + public synchronized Optional findByUserId(String userId) { + return Optional.ofNullable(byId.get(userId)); + } + + @Override + public synchronized Optional findByUsername(String username) { + String id = usernameToId.get(username); + return id == null ? Optional.empty() : Optional.ofNullable(byId.get(id)); + } + + @Override + public synchronized boolean existsByUsername(String username) { + return usernameToId.containsKey(username); + } + + @Override + public synchronized UserAccount save(UserAccount account) { + byId.put(account.getUserId(), account); + usernameToId.put(account.getUsername(), account.getUserId()); + return account; + } + + @Override + public synchronized List listAll() { + return List.copyOf(byId.values()); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaBindingStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaBindingStore.java new file mode 100644 index 0000000..a13d87c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaBindingStore.java @@ -0,0 +1,46 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceBinding; +import com.ttstd.signaling.repository.DeviceBindingRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** JPA 实现:绑定关系持久化到 MySQL。仅在 storage.mode=jpa(默认)时生效。 */ +@Component +@Profile("!memory") +public class JpaBindingStore implements BindingStore { + + private final DeviceBindingRepository repository; + + public JpaBindingStore(DeviceBindingRepository repository) { + this.repository = repository; + } + + @Override + public DeviceBinding save(DeviceBinding binding) { + return repository.save(binding); + } + + @Override + public Optional findByDeviceUidAndUserId(String deviceUid, String userId) { + return repository.findByDeviceUidAndUserId(deviceUid, userId); + } + + @Override + public List listByDeviceUid(String deviceUid) { + return repository.findByDeviceUid(deviceUid); + } + + @Override + public List listByUserId(String userId) { + return repository.findByUserId(userId); + } + + @Override + public void delete(String bindingId) { + repository.deleteById(bindingId); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaBlacklistStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaBlacklistStore.java new file mode 100644 index 0000000..0f39d95 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaBlacklistStore.java @@ -0,0 +1,47 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceBlacklistEntry; +import com.ttstd.signaling.repository.DeviceBlacklistRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** JPA 实现:黑名单持久化到 MySQL。仅在 storage.mode=jpa(默认)时生效。 */ +@Component +@Profile("!memory") +public class JpaBlacklistStore implements BlacklistStore { + + private final DeviceBlacklistRepository repository; + + public JpaBlacklistStore(DeviceBlacklistRepository repository) { + this.repository = repository; + } + + @Override + public DeviceBlacklistEntry save(DeviceBlacklistEntry entry) { + return repository.save(entry); + } + + @Override + public Optional findByDeviceUidAndBlockedUserId( + String deviceUid, String blockedUserId) { + return repository.findByDeviceUidAndBlockedUserId(deviceUid, blockedUserId); + } + + @Override + public boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId) { + return repository.existsByDeviceUidAndBlockedUserId(deviceUid, blockedUserId); + } + + @Override + public List listByDeviceUid(String deviceUid) { + return repository.findByDeviceUid(deviceUid); + } + + @Override + public void delete(String deviceUid, String blockedUserId) { + repository.deleteById(new DeviceBlacklistEntry.PK(deviceUid, blockedUserId)); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaDeviceStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaDeviceStore.java new file mode 100644 index 0000000..043dd1c --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaDeviceStore.java @@ -0,0 +1,66 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.DeviceAccount; +import com.ttstd.signaling.model.DeviceAllowlist; +import com.ttstd.signaling.repository.DeviceAccountRepository; +import com.ttstd.signaling.repository.DeviceAllowlistRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** JPA 实现:设备身份与 SN 白名单持久化到 MySQL。仅在 storage.mode=jpa(默认)时生效。 */ +@Component +@Profile("!memory") +public class JpaDeviceStore implements DeviceStore { + + private final DeviceAccountRepository deviceRepository; + private final DeviceAllowlistRepository allowlistRepository; + + public JpaDeviceStore(DeviceAccountRepository deviceRepository, + DeviceAllowlistRepository allowlistRepository) { + this.deviceRepository = deviceRepository; + this.allowlistRepository = allowlistRepository; + } + + @Override + public Optional findByDeviceUid(String deviceUid) { + return deviceRepository.findById(deviceUid); + } + + @Override + public Optional findBySn(String sn) { + return deviceRepository.findBySn(sn); + } + + @Override + public boolean existsBySn(String sn) { + return deviceRepository.existsBySn(sn); + } + + @Override + public DeviceAccount save(DeviceAccount account) { + return deviceRepository.save(account); + } + + @Override + public List listAll() { + return deviceRepository.findAll(); + } + + @Override + public boolean isSnAllowed(String sn) { + return allowlistRepository.existsBySn(sn); + } + + @Override + public void addAllowedSn(DeviceAllowlist entry) { + allowlistRepository.save(entry); + } + + @Override + public List listAllowed() { + return allowlistRepository.findAll(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaPairingStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaPairingStore.java new file mode 100644 index 0000000..732a3c0 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaPairingStore.java @@ -0,0 +1,35 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.PairingCode; +import com.ttstd.signaling.repository.PairingCodeRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** JPA 实现,配对码持久化到 MySQL(storage.mode=jpa,默认)。 */ +@Component +@Profile("!memory") +public class JpaPairingStore implements PairingStore { + + private final PairingCodeRepository repository; + + public JpaPairingStore(PairingCodeRepository repository) { + this.repository = repository; + } + + @Override + public PairingCode save(PairingCode code) { + return repository.save(code); + } + + @Override + public Optional findByCodeHash(String codeHash) { + return repository.findByCodeHash(codeHash); + } + + @Override + public void delete(String codeHash) { + repository.deleteById(codeHash); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaSessionStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaSessionStore.java new file mode 100644 index 0000000..63a75ef --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaSessionStore.java @@ -0,0 +1,72 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.LoginSession; +import com.ttstd.signaling.model.PrincipalType; +import com.ttstd.signaling.repository.LoginSessionRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** JPA 实现:登录会话持久化到 MySQL。仅在 storage.mode=jpa(默认)时生效。 */ +@Component +@Profile("!memory") +public class JpaSessionStore implements SessionStore { + + private final LoginSessionRepository repository; + + public JpaSessionStore(LoginSessionRepository repository) { + this.repository = repository; + } + + @Override + public LoginSession save(LoginSession session) { + return repository.save(session); + } + + @Override + public Optional findBySessionId(String sessionId) { + return repository.findById(sessionId); + } + + @Override + public List listByPrincipal(String principalId) { + return repository.findByPrincipalId(principalId); + } + + @Override + public List listByPrincipalAndType(String principalId, PrincipalType type) { + return repository.findByPrincipalIdAndPrincipalType(principalId, type); + } + + @Override + public List listAll() { + return repository.findAll(); + } + + @Override + public void delete(String sessionId) { + repository.deleteBySessionId(sessionId); + } + + @Override + public void deleteByPrincipal(String principalId) { + repository.findByPrincipalId(principalId).forEach(s -> repository.delete(s)); + } + + @Override + public int revokeByPrincipal(String principalId) { + return repository.revokeByPrincipalId(principalId); + } + + @Override + public int revokeBySessionId(String sessionId) { + return repository.revokeBySessionId(sessionId); + } + + @Override + public long countByPrincipalAndType(String principalId, PrincipalType type) { + return repository.countByPrincipalIdAndPrincipalType(principalId, type); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaUserStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaUserStore.java new file mode 100644 index 0000000..9ae56ff --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/JpaUserStore.java @@ -0,0 +1,46 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.UserAccount; +import com.ttstd.signaling.repository.UserAccountRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** JPA 实现:用户账号持久化到 MySQL。仅在 storage.mode=jpa(默认)时生效。 */ +@Component +@Profile("!memory") +public class JpaUserStore implements UserStore { + + private final UserAccountRepository repository; + + public JpaUserStore(UserAccountRepository repository) { + this.repository = repository; + } + + @Override + public Optional findByUserId(String userId) { + return repository.findById(userId); + } + + @Override + public Optional findByUsername(String username) { + return repository.findByUsername(username); + } + + @Override + public boolean existsByUsername(String username) { + return repository.existsByUsername(username); + } + + @Override + public UserAccount save(UserAccount account) { + return repository.save(account); + } + + @Override + public List listAll() { + return repository.findAll(); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/NonceStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/NonceStore.java new file mode 100644 index 0000000..acb92c9 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/NonceStore.java @@ -0,0 +1,14 @@ +package com.ttstd.signaling.store; + +/** + * 一次性 nonce 去重存储(防重放)。优先使用 Redis(多实例共享),不可用时降级为内存。 + */ +public interface NonceStore { + /** + * 若 nonce 不存在则记录并返回 true;已存在则返回 false。 + * + * @param nonce 待校验的随机串 + * @param ttlSeconds 过期秒数 + */ + boolean tryReserve(String nonce, long ttlSeconds); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/PairingStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/PairingStore.java new file mode 100644 index 0000000..1af4c9d --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/PairingStore.java @@ -0,0 +1,14 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.PairingCode; +import java.util.Optional; + +/** 配对码存储抽象。 */ +public interface PairingStore { + + PairingCode save(PairingCode code); + + Optional findByCodeHash(String codeHash); + + void delete(String codeHash); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/RedisNonceStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/RedisNonceStore.java new file mode 100644 index 0000000..9203f45 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/RedisNonceStore.java @@ -0,0 +1,33 @@ +package com.ttstd.signaling.store; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * 基于 Redis 的 nonce 去重实现(多实例共享,推荐生产使用)。 + * + *

通过 {@code SET key value NX EX ttl} 原子操作保证并发安全;Redis 不可用时 + * 调用方应降级到 {@link InMemoryNonceStore}(由配置选择),本类不内嵌降级逻辑。 + */ +@Component +@org.springframework.context.annotation.Profile("!memory") +public class RedisNonceStore implements NonceStore { + + private static final Logger logger = LoggerFactory.getLogger(RedisNonceStore.class); + private final StringRedisTemplate redis; + + public RedisNonceStore(StringRedisTemplate redis) { + this.redis = redis; + } + + @Override + public boolean tryReserve(String nonce, long ttlSeconds) { + String key = "webrtc:nonce:" + nonce; + Boolean ok = redis.opsForValue().setIfAbsent(key, "1", ttlSeconds, TimeUnit.SECONDS); + return Boolean.TRUE.equals(ok); + } +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/SessionStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/SessionStore.java new file mode 100644 index 0000000..70aaf97 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/SessionStore.java @@ -0,0 +1,23 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.LoginSession; +import com.ttstd.signaling.model.PrincipalType; + +import java.util.List; +import java.util.Optional; + +/** + * 登录会话存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。 + */ +public interface SessionStore { + LoginSession save(LoginSession session); + Optional findBySessionId(String sessionId); + List listByPrincipal(String principalId); + List listByPrincipalAndType(String principalId, PrincipalType type); + List listAll(); + void delete(String sessionId); + void deleteByPrincipal(String principalId); + int revokeByPrincipal(String principalId); + int revokeBySessionId(String sessionId); + long countByPrincipalAndType(String principalId, PrincipalType type); +} diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/UserStore.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/UserStore.java new file mode 100644 index 0000000..77149b0 --- /dev/null +++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/store/UserStore.java @@ -0,0 +1,17 @@ +package com.ttstd.signaling.store; + +import com.ttstd.signaling.model.UserAccount; + +import java.util.List; +import java.util.Optional; + +/** + * 用户账号存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。 + */ +public interface UserStore { + Optional findByUserId(String userId); + Optional findByUsername(String username); + boolean existsByUsername(String username); + UserAccount save(UserAccount account); + List listAll(); +} diff --git a/WebRTCSignalServer/src/main/resources/application-memory.yml b/WebRTCSignalServer/src/main/resources/application-memory.yml new file mode 100644 index 0000000..c72e737 --- /dev/null +++ b/WebRTCSignalServer/src/main/resources/application-memory.yml @@ -0,0 +1,15 @@ +# memory profile:不连接任何外部存储,全部使用内存实现(用于本地演示与测试)。 +# 通过环境变量 STORAGE_MODE=memory 或启动参数 --spring.profiles.active=memory 激活。 +spring: + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration + - org.springframework.boot.autoconfigure.jdbc.DataSourceInitializationAutoConfiguration + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + +storage: + mode: memory diff --git a/WebRTCSignalServer/src/main/resources/application.yml b/WebRTCSignalServer/src/main/resources/application.yml index 39acf64..8bd5553 100644 --- a/WebRTCSignalServer/src/main/resources/application.yml +++ b/WebRTCSignalServer/src/main/resources/application.yml @@ -5,19 +5,114 @@ spring: application: name: webrtc-signal-server + # ============ 持久化 ============ + # 存储模式:jpa=使用 MySQL(生产);memory=纯内存(测试/演示) + config: + activate: + on-profile: default + storage: + mode: ${STORAGE_MODE:jpa} + + # MySQL 数据源(敏感信息全部走环境变量,禁止硬编码) + datasource: + url: ${DB_URL:jdbc:mysql://175.178.213.60:33306/webrtc_signal?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:fanhuitong} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + # 首次部署自动建表;生产可改为 validate 并改用 Flyway/Liquibase 管理 schema + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + jdbc: + time_zone: UTC + + # Redis:在线状态、频控计数、nonce 去重、踢线广播 + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + timeout: 3000ms + lettuce: + pool: + max-active: 16 + max-idle: 8 + min-idle: 2 + max-wait: 2000ms + +redis: + # 频控/nonce 等计数键前缀与默认 TTL(秒) + key-prefix: "webrtc:" + online-key-prefix: "webrtc:online:" + flush-key-prefix: "webrtc:flush:" + # 后台管理接口鉴权令牌;可通过环境变量 ADMIN_TOKEN 覆盖。 # WebRTCSignalServerWeb 登录时使用的默认令牌即为此值。 admin: token: ${ADMIN_TOKEN:webrtc-admin-token} +security: + jwt: + # 【生产环境必须通过环境变量 JWT_SECRET 覆盖】留空时服务启动会随机生成密钥, + # 随机密钥会导致服务重启后所有已签发 token 失效,且多实例部署无法互认。 + secret: ${JWT_SECRET:} + issuer: webrtc-signal-server + # 访问令牌有效期(秒),默认 15 分钟 + access-token-ttl-seconds: 900 + # 刷新令牌有效期(秒),默认 7 天 + refresh-token-ttl-seconds: 604800 + # 被控端设备访问令牌有效期(秒),默认 15 分钟 + device-token-ttl-seconds: 900 + # 允许的时钟偏移(秒) + clock-skew-seconds: 60 + + device: + # 设备激活(provision)共享密钥:被控端为系统签名应用,内置同一密钥用于 HMAC 签名。 + # 【生产环境必须通过环境变量 DEVICE_PROVISION_SECRET 覆盖】 + provision-secret: ${DEVICE_PROVISION_SECRET:} + # 激活请求时间戳允许的偏移(秒),用于防重放 + provision-skew-seconds: 300 + # 是否启用 SN 白名单:启用后仅允许已导入白名单的 SN 激活 + sn-allowlist-enabled: ${DEVICE_SN_ALLOWLIST_ENABLED:false} + + account: + # 是否开放自助注册接口 + registration-enabled: ${ACCOUNT_REGISTRATION_ENABLED:true} + # 登录失败锁定阈值与锁定时长(秒) + max-failed-attempts: 5 + lock-duration-seconds: 900 + # 单账号最大并发会话数,超出时踢掉最旧会话 + max-concurrent-sessions: 5 + # 首次启动时自动创建的初始管理员账号(仅当账号不存在时创建) + bootstrap-username: ${BOOTSTRAP_ADMIN_USERNAME:admin} + bootstrap-password: ${BOOTSTRAP_ADMIN_PASSWORD:} + + websocket: + # WebSocket 允许的来源,逗号分隔;生产环境应收敛为具体域名 + allowed-origins: ${WS_ALLOWED_ORIGINS:*} + + # TURN 短期凭证(RFC 7635 风格):为 PeerConnection 提供中继能力 + turn: + # 是否启用 TURN 凭证发放 + enabled: ${TURN_ENABLED:false} + # TURN 共享密钥(HMAC key),【生产环境必须通过环境变量 TURN_SHARED_SECRET 覆盖】 + shared-secret: ${TURN_SHARED_SECRET:} + # TURN 服务器地址,逗号分隔,如 turn:turn.ttstd.com:3478?transport=udp + urls: ${TURN_URLS:} + # 短期凭证有效期(秒) + ttl-seconds: ${TURN_TTL_SECONDS:3600} + + # 配对码有效期(秒),默认 10 分钟 + pairing: + ttl-seconds: ${PAIRING_TTL_SECONDS:600} + logging: level: com.ttstd.signaling: DEBUG org.springframework.web.socket: DEBUG - - datasource: - type: com.alibaba.druid.pool.DruidDataSource - driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置 - url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true - username: root - password: fanhuitong \ No newline at end of file diff --git a/WebRTCSignalServer/src/test/java/com/ttstd/signaling/ApplicationContextTest.java b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/ApplicationContextTest.java new file mode 100644 index 0000000..d4842aa --- /dev/null +++ b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/ApplicationContextTest.java @@ -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); + } +} diff --git a/WebRTCSignalServer/src/test/java/com/ttstd/signaling/AuthFlowTest.java b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/AuthFlowTest.java new file mode 100644 index 0000000..8aacb16 --- /dev/null +++ b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/AuthFlowTest.java @@ -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); + } + } +} diff --git a/WebRTCSignalServer/src/test/java/com/ttstd/signaling/BindingServiceTest.java b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/BindingServiceTest.java new file mode 100644 index 0000000..e78f5b4 --- /dev/null +++ b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/BindingServiceTest.java @@ -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")); + } +} diff --git a/WebRTCSignalServer/src/test/java/com/ttstd/signaling/PairingAndTurnTest.java b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/PairingAndTurnTest.java new file mode 100644 index 0000000..b08bd20 --- /dev/null +++ b/WebRTCSignalServer/src/test/java/com/ttstd/signaling/PairingAndTurnTest.java @@ -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 creds = turn.issue("controller:usr_1"); + assertTrue(turn.isEnabled()); + List ice = (List) creds.get("iceServers"); + assertNotNull(ice); + assertEquals(1, ice.size()); + @SuppressWarnings("unchecked") + Map server = (Map) 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")); + } +} diff --git a/webrtc_controller_flutter/lib/api/api_client.dart b/webrtc_controller_flutter/lib/api/api_client.dart new file mode 100644 index 0000000..e8cd43f --- /dev/null +++ b/webrtc_controller_flutter/lib/api/api_client.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; + +/// 服务端 HTTP 基址(与信令同源)。部署时通过 --dart-define=API_BASE= 注入。 +const String kApiBase = String.fromEnvironment( + 'API_BASE', + defaultValue: 'https://www.ttstd.com', +); + +/// 账号体系与自助接口封装(对应服务端 /api/auth/* 与 /api/client/*)。 +/// +/// 令牌存储策略: +/// - accessToken:内存保存(掉线即失,需重新登录); +/// - refreshToken:flutter_secure_storage(Keychain / EncryptedSharedPreferences,一次性,ses_ 前缀)。 +class ApiClient { + ApiClient({http.Client? client}) : _http = client ?? http.Client(); + + final http.Client _http; + static const _storage = FlutterSecureStorage(); + + static const _kAccess = 'ttstd.accessToken'; + static const _kRefresh = 'ttstd.refreshToken'; + static const _kUsername = 'ttstd.username'; + + // 刷新单飞:避免并发触发多次刷新。 + Future>? _refreshing; + + String? _accessToken; + String? get accessToken => _accessToken; + + /// 从安全存储恢复令牌(应用启动时调用)。 + Future restore() async { + _accessToken = await _storage.read(key: _kAccess); + } + + bool get hasTokens { + // 同时读取内存 accessToken 与持久 refreshToken 判断。 + return _accessToken != null; + } + + Future hasRefreshToken() async => + (await _storage.read(key: _kRefresh)) != null; + + Future saveTokens({ + required String accessToken, + String? refreshToken, + String? username, + }) async { + _accessToken = accessToken; + await _storage.write(key: _kAccess, value: accessToken); + if (refreshToken != null) { + await _storage.write(key: _kRefresh, value: refreshToken); + } + if (username != null) { + await _storage.write(key: _kUsername, value: username); + } + } + + Future getUsername() => _storage.read(key: _kUsername); + + Future clear() async { + _accessToken = null; + await _storage.delete(key: _kAccess); + await _storage.delete(key: _kRefresh); + await _storage.delete(key: _kUsername); + } + + Future> login(String username, String password) async { + final data = await _post('/api/auth/login', + body: {'username': username, 'password': password}); + await saveTokens( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String?, + username: username, + ); + return data; + } + + Future> register(String username, String password) => + _post('/api/auth/register', + body: {'username': username, 'password': password}); + + /// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。 + Future> refresh() async { + final rt = await _storage.read(key: _kRefresh); + if (rt == null) { + await clear(); + throw ApiException('NO_REFRESH_TOKEN', 401); + } + _refreshing ??= _doRefresh(rt).whenComplete(() => _refreshing = null); + return _refreshing!; + } + + Future> _doRefresh(String rt) async { + final data = await _post('/api/auth/refresh', body: {'refreshToken': rt}); + await saveTokens( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String?, + ); + return data; + } + + Future> verify() => _get('/api/client/verify'); + + Future> bindings() => _get('/api/client/bindings'); + + /// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回空 Map。 + Future?> turnCredentials() async { + try { + return await _get('/api/client/turn-credentials'); + } on ApiException { + return null; + } + } + + Future> _post(String path, {required Map body}) async { + final res = await _http.post( + Uri.parse('$kApiBase$path'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + return _handle(res); + } + + Future> _get(String path) async { + final res = await _http.get( + Uri.parse('$kApiBase$path'), + headers: { + 'Content-Type': 'application/json', + if (_accessToken != null) 'Authorization': 'Bearer $_accessToken', + }, + ); + return _handle(res); + } + + Map _handle(http.Response res) { + final body = res.body.isNotEmpty ? jsonDecode(res.body) : {}; + if (res.statusCode == 401) { + throw ApiException(body['code'] ?? 'UNAUTHORIZED', 401); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw ApiException(body['error'] ?? 'HTTP ${res.statusCode}', res.statusCode); + } + return body as Map; + } +} + +/// API 调用异常,携带服务端 code 与 HTTP 状态码。 +class ApiException implements Exception { + final String code; + final int httpCode; + ApiException(this.code, this.httpCode); + + @override + String toString() => code; +} diff --git a/webrtc_controller_flutter/lib/controller/remote_controller.dart b/webrtc_controller_flutter/lib/controller/remote_controller.dart index f5ea57d..47dbe91 100644 --- a/webrtc_controller_flutter/lib/controller/remote_controller.dart +++ b/webrtc_controller_flutter/lib/controller/remote_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import '../api/api_client.dart'; import '../models/signal_message.dart'; import '../proto/control_message.pb.dart'; import '../signaling/signaling_client.dart'; @@ -12,8 +13,10 @@ import '../webrtc/webrtc_controller.dart'; /// 对外暴露连接/断开/发送指令等高层接口(对应 Android 端 MainActivity 的流程)。 class RemoteController { final String serverUrl; - final String deviceId; final String targetDeviceId; + final ApiClient apiClient; + String? token; + String? authType; String? authValue; @@ -21,7 +24,10 @@ class RemoteController { WebRtcController? _webRtc; Timer? _statsTimer; - /// 信令状态变化(如“正在连接…”、“已连接…”)。 + /// 本地设备ID(由服务端 REGISTER_SUCCESS 下发)。 + String? _myDeviceId; + + /// 信令状态变化(如"正在连接…"、"已连接…)。 void Function(String status)? onStatusChanged; /// WebRTC 连接建立(可开始远程控制)。 @@ -67,24 +73,39 @@ class RemoteController { /// 当前平台不支持原生硬解时回调。 void Function()? onSelfCodecNotSupported; + /// 令牌失效(4001):用于触发刷新重连。 + void Function()? onTokenExpired; + + /// 强制下线(4003):用于跳回登录。 + void Function()? onForceLogout; + RemoteController({ required this.serverUrl, - required this.deviceId, required this.targetDeviceId, + required this.apiClient, + this.token, this.authType, this.authValue, }); - /// 发起连接:先连接信令服务器,成功后建立 WebRTC 并创建 Offer。 - void connect({String? authType, String? authValue}) { + /// 发起连接:先确保 accessToken,成功后携带 Bearer 建立 WebSocket, + /// 待 REGISTER_SUCCESS 拿到本机 deviceId 再建立 WebRTC 并创建 Offer。 + Future connect({String? authType, String? authValue}) async { this.authType = authType; this.authValue = authValue; onStatusChanged?.call('状态: 正在连接信令服务器...'); - _signaling = SignalingClient(serverUrl: serverUrl, deviceId: deviceId); + try { + token = await _ensureToken(); + } catch (e) { + onStatusChanged?.call('状态: 认证失败 - $e'); + onConnectionFailed?.call(e.toString()); + return; + } + + _signaling = SignalingClient(serverUrl: serverUrl, token: token); _signaling.onConnected = () { - onStatusChanged?.call('状态: 已连接信令服务器,正在发起连接...'); - _initWebRtc(); + onStatusChanged?.call('状态: 已连接信令服务器,等待注册...'); }; _signaling.onMessage = _handleSignalMessage; _signaling.onDisconnected = () { @@ -95,10 +116,49 @@ class RemoteController { onStatusChanged?.call('状态: 连接错误 - $error'); onConnectionFailed?.call(error); }; + _signaling.onTokenExpired = () async { + try { + await apiClient.refresh(); + token = apiClient.accessToken; + _reconnect(); + } catch (e) { + onStatusChanged?.call('状态: 令牌刷新失败 - $e'); + onTokenExpired?.call(); + } + }; + _signaling.onForceLogout = () { + onStatusChanged?.call('状态: 账号已在其他位置登录,已强制下线'); + onForceLogout?.call(); + }; + _signaling.connect(); + } + + /// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。 + Future _ensureToken() async { + final existing = apiClient.accessToken; + if (existing != null) { + try { + await apiClient.verify(); + return existing; + } on ApiException catch (e) { + if (e.httpCode != 401) return existing; + } + } + final data = await apiClient.refresh(); + return data['accessToken'] as String; + } + + void _reconnect() { + _signaling.disconnect(); _signaling.connect(); } void _initWebRtc() { + final deviceId = _myDeviceId; + if (deviceId == null) { + onStatusChanged?.call('状态: 未获取到本机设备ID,连接中止'); + return; + } _webRtc = WebRtcController( signaling: _signaling, deviceId: deviceId, @@ -123,6 +183,7 @@ class RemoteController { _webRtc!.onFpsReport = (w, h, fps, list) => onFpsReport?.call(w, h, fps, list); _webRtc!.onSelfCodecNotSupported = () => onSelfCodecNotSupported?.call(); + onStatusChanged?.call('状态: 已注册 ($deviceId),正在发起连接...'); _webRtc!.initialize().catchError((e) { onStatusChanged?.call('状态: 连接失败 - $e'); onConnectionFailed?.call(e.toString()); @@ -130,10 +191,20 @@ class RemoteController { } void _handleSignalMessage(SignalMessage message) { - switch (message.type?.toUpperCase()) { + final type = message.type?.toUpperCase(); + if (type == 'REGISTER_SUCCESS') { + // 服务端下发本机 deviceId,作为后续 OFFER 的 fromDeviceId。 + _myDeviceId = message.fromDeviceId; + _initWebRtc(); + // 拉取可连接的被控端绑定列表(仅已绑定设备)。 + _loadBindings(); + // 尝试用服务端 TURN 凭证覆盖默认 ICE 配置。 + _loadTurnCredentials(); + return; + } + switch (type) { case 'ANSWER': final payload = jsonDecode(message.payload!) as Map; - // 被控端已接受连接请求,进入 WebRTC 协商阶段。 onStatusChanged?.call('状态: 被控端已接受连接,正在建立连接...'); _webRtc?.handleAnswer(payload['sdp'] as String); break; @@ -167,6 +238,40 @@ class RemoteController { return payload; } + /// 拉取本机可连接的被控端绑定列表(仅已绑定设备),供 UI 提示。 + Future _loadBindings() async { + try { + final data = await apiClient.bindings(); + final list = (data['bindings'] as List?) ?? []; + if (list.isNotEmpty) { + final uids = list.map((e) { + if (e is Map) { + return (e['deviceUid'] ?? e['deviceId'] ?? '').toString(); + } + return e.toString(); + }).where((s) => s.isNotEmpty).join(', '); + if (uids.isNotEmpty) { + onStatusChanged?.call('已绑定设备: $uids'); + } + } + } catch (_) { + // 绑定列表拉取失败不影响主流程。 + } + } + + /// 拉取 TURN 短期凭证,覆盖默认 ICE 配置(服务端开启时)。 + Future _loadTurnCredentials() async { + final data = await apiClient.turnCredentials(); + if (data != null && data['iceServers'] is List) { + final servers = (data['iceServers'] as List) + .map((e) => Map.from(e as Map)) + .toList(); + if (servers.isNotEmpty) { + WebRtcController.iceServersOverride = servers; + } + } + } + void _startStats() { _statsTimer?.cancel(); _statsTimer = Timer.periodic(const Duration(seconds: 1), (_) async { diff --git a/webrtc_controller_flutter/lib/main.dart b/webrtc_controller_flutter/lib/main.dart index 1f0ccf5..cf0e0e5 100644 --- a/webrtc_controller_flutter/lib/main.dart +++ b/webrtc_controller_flutter/lib/main.dart @@ -4,10 +4,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'api/api_client.dart'; import 'config/ice_servers.dart'; import 'controller/remote_controller.dart'; import 'utils/control_commands.dart'; -import 'utils/device_utils.dart'; import 'webrtc/self_codec_decoder.dart'; import 'webrtc/video_recorder.dart'; import 'widgets/remote_touch_view.dart'; @@ -48,6 +48,11 @@ class _ControllerHomeState extends State { ); final _deviceIdController = TextEditingController(); final _targetController = TextEditingController(text: '981964879'); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + /// 账号 API 客户端(登录 / 刷新 / 绑定列表 / TURN)。 + final ApiClient _apiClient = ApiClient(); RemoteController? _controller; RTCVideoRenderer? _renderer; @@ -56,6 +61,9 @@ class _ControllerHomeState extends State { /// 是否正在连接信令服务器 / 建立 WebRTC(连接过程中禁用“连接”按钮)。 bool _connecting = false; String _status = '状态: 已停止'; + + /// 是否已登录(accessToken 存在)。 + bool _loggedIn = false; String _stats = ''; double _videoAspect = 16 / 9; @@ -95,15 +103,14 @@ class _ControllerHomeState extends State { @override void initState() { super.initState(); - _initDeviceId(); - } - - /// 异步获取设备标识并填入设备ID输入框(非系统签名,使用兜底方案)。 - Future _initDeviceId() async { - final id = await DeviceUtils.getSerialNumber(); - if (mounted) { - setState(() => _deviceIdController.text = id); - } + // 启动时从安全存储恢复令牌;设备ID 由服务端注册后下发,无需本地生成。 + _apiClient.restore().then((_) async { + if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) { + setState(() { + _loggedIn = true; + }); + } + }); } @override @@ -137,12 +144,14 @@ class _ControllerHomeState extends State { Future _showAuthDialog() async { final serverUrl = _serverUrlController.text.trim(); - final deviceId = _deviceIdController.text.trim(); final target = _targetController.text.trim(); - if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) { - _setStatus('请填写所有字段'); + if (serverUrl.isEmpty || target.isEmpty) { + _setStatus('请填写服务器地址和目标设备ID'); return; } + // 连接前确保已登录(Bearer token)。 + final authed = await _ensureLoggedIn(); + if (!authed) return; String selectedType = 'NONE'; final valueController = TextEditingController(); @@ -279,11 +288,10 @@ class _ControllerHomeState extends State { Future _connect({required String authType, required String authValue}) async { final serverUrl = _serverUrlController.text.trim(); - final deviceId = _deviceIdController.text.trim(); final target = _targetController.text.trim(); - if (serverUrl.isEmpty || deviceId.isEmpty || target.isEmpty) { - _setStatus('请填写所有字段'); + if (serverUrl.isEmpty || target.isEmpty) { + _setStatus('请填写服务器地址和目标设备ID'); return; } @@ -292,8 +300,9 @@ class _ControllerHomeState extends State { _controller = RemoteController( serverUrl: serverUrl, - deviceId: deviceId, targetDeviceId: target, + apiClient: _apiClient, + token: _apiClient.accessToken, authType: authType, authValue: authValue, ); @@ -378,10 +387,99 @@ class _ControllerHomeState extends State { if (mounted) setState(() => _selfCodecSupported = false); _showAlert('当前平台不支持自编码硬解,已回退到 WebRTC 媒体流。'); }; + _controller!.onTokenExpired = () { + _showAlert('登录已失效,请重新登录后再连接。'); + _resetLogin(); + }; + _controller!.onForceLogout = () { + _showAlert('账号已在其他位置登录,已强制下线。'); + _resetLogin(); + }; _controller!.connect(authType: authType, authValue: authValue); } + /// 确保已登录:若已有 refreshToken 则直接返回;否则弹出登录对话框,登录成功后返回。 */ + Future _ensureLoggedIn() async { + if (_apiClient.accessToken != null && await _apiClient.hasRefreshToken()) { + return true; + } + final login = await _showLoginDialog(); + return login; + } + + /// 显示登录对话框:输入用户名/密码,调用 /api/auth/login 保存令牌。 + Future _showLoginDialog() async { + _usernameController.clear(); + _passwordController.clear(); + final result = await showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('登录'), + content: Padding( + padding: const EdgeInsets.only(top: 12), + child: Column( + children: [ + CupertinoTextField( + controller: _usernameController, + placeholder: '用户名', + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + ), + const SizedBox(height: 10), + CupertinoTextField( + controller: _passwordController, + placeholder: '密码', + obscureText: true, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + ), + ], + ), + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.of(ctx).pop(false), + ), + CupertinoDialogAction( + child: const Text('登录'), + onPressed: () async { + final user = _usernameController.text.trim(); + final pass = _passwordController.text.trim(); + if (user.isEmpty || pass.isEmpty) { + _showAlert('请输入用户名和密码'); + return; + } + try { + await _apiClient.login(user, pass); + if (mounted) { + setState(() { + _loggedIn = true; + }); + } + Navigator.of(ctx).pop(true); + } catch (e) { + _showAlert('登录失败:$e'); + } + }, + ), + ], + ), + ); + return result ?? false; + } + + /// 退出登录并复位 UI 状态(清空令牌、停止连接)。 + Future _resetLogin() async { + await _apiClient.clear(); + await _disconnect(); + if (mounted) { + setState(() { + _loggedIn = false; + _deviceIdController.clear(); + }); + } + } + void _onRendererUpdate() { final w = _renderer?.value.width ?? 0; final h = _renderer?.value.height ?? 0; @@ -636,11 +734,12 @@ class _ControllerHomeState extends State { padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), ), const SizedBox(height: 16), - const Text('本机设备ID:', style: TextStyle(fontSize: 14)), + const Text('本机设备ID(连接后由服务端下发,无需填写):', style: TextStyle(fontSize: 14)), const SizedBox(height: 8), CupertinoTextField( controller: _deviceIdController, - placeholder: '设备ID', + placeholder: '连接后由服务端下发', + enabled: false, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), ), const SizedBox(height: 16), @@ -656,6 +755,21 @@ class _ControllerHomeState extends State { _status, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: CupertinoButton.filled( + onPressed: _loggedIn + ? () async { + await _resetLogin(); + } + : () async { + final ok = await _showLoginDialog(); + if (!ok) _setStatus('请先登录后再连接'); + }, + child: Text(_loggedIn ? '退出登录' : '登录账号'), + ), + ), const SizedBox(height: 24), SizedBox( width: double.infinity, diff --git a/webrtc_controller_flutter/lib/signaling/signaling_client.dart b/webrtc_controller_flutter/lib/signaling/signaling_client.dart index 1209fa7..58a489a 100644 --- a/webrtc_controller_flutter/lib/signaling/signaling_client.dart +++ b/webrtc_controller_flutter/lib/signaling/signaling_client.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:web_socket_channel/web_socket_channel.dart'; @@ -6,13 +7,18 @@ import '../models/signal_message.dart'; /// 信令客户端,封装 WebSocket 连接与消息收发。 /// -/// 连接成功后自动向服务器发送 REGISTER 注册为 CONTROLLER 设备, -/// 与 Android 端 WebSocketClient 行为一致。 +/// 鉴权方式:浏览器/移动端原生 WebSocket 均可在握手阶段通过子协议 +/// (Sec-WebSocket-Protocol)传递 Bearer token: +/// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.``']) +/// 服务端从首子协议取出 auth.`` 进行校验。 +/// +/// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。 class SignalingClient { final String serverUrl; - final String deviceId; + final String? token; WebSocketChannel? _channel; + Timer? _heartbeatTimer; /// 收到信令消息回调(已解析为 SignalMessage)。 void Function(SignalMessage message)? onMessage; @@ -26,23 +32,33 @@ class SignalingClient { /// 连接错误回调。 void Function(String error)? onError; - SignalingClient({required this.serverUrl, required this.deviceId}); + /// 令牌失效(关闭码 4001):调用方应刷新令牌后重连。 + void Function()? onTokenExpired; - /// 建立 WebSocket 连接并注册设备。 + /// 强制下线(关闭码 4003):调用方应停止重连并跳登录。 + void Function()? onForceLogout; + + SignalingClient({required this.serverUrl, this.token}); + + /// 建立 WebSocket 连接(携带 Bearer 子协议,不再发送 REGISTER)。 void connect() { try { final uri = Uri.parse(serverUrl); - _channel = WebSocketChannel.connect(uri); + final protocols = ['signal.v1']; + if (token != null && token!.isNotEmpty) { + protocols.add('auth.$token'); + } + _channel = WebSocketChannel.connect(uri, protocols: protocols); _channel!.stream.listen( _onData, - onDone: () => onDisconnected?.call(), + onDone: _onDone, onError: (Object e) => onError?.call(e.toString()), cancelOnError: false, ); - // 连接建立后注册设备(与 Android 端 onOpen -> registerDevice 对应)。 - _register(); + _startHeartbeat(); + onConnected?.call(); } catch (e) { onError?.call(e.toString()); } @@ -53,22 +69,45 @@ class SignalingClient { try { final map = jsonDecode(data) as Map; final message = SignalMessage.fromJson(map); + // 记录服务端下发的本机 deviceId(REGISTER_SUCCESS.fromDeviceId)。 + if (message.type?.toUpperCase() == 'REGISTER_SUCCESS' && + message.fromDeviceId != null) { + // 透传给上层,由 RemoteController 处理。 + } onMessage?.call(message); } catch (_) { // 忽略无法解析的消息。 } } - void _register() { - final msg = SignalMessage( - type: 'REGISTER', - fromDeviceId: deviceId, - deviceType: 'CONTROLLER', - ); - _send(msg); - // 通知上层已“连接”(WebSocketChannel 会缓冲发送,直到底层连接就绪); - // 后续 OFFER 等消息会排在 REGISTER 之后发送,保证服务器先完成注册。 - onConnected?.call(); + void _onDone() { + _stopHeartbeat(); + final code = _channel?.closeCode; + if (code == 4001) { + onTokenExpired?.call(); + return; + } + if (code == 4003) { + onForceLogout?.call(); + return; + } + onDisconnected?.call(); + } + + void _startHeartbeat() { + _stopHeartbeat(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 25), (_) { + if (_channel != null) { + try { + _channel!.sink.add(jsonEncode({'type': 'PING'})); + } catch (_) {} + } + }); + } + + void _stopHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; } /// 发送信令消息。 @@ -80,6 +119,7 @@ class SignalingClient { /// 关闭连接。 void disconnect() { + _stopHeartbeat(); _channel?.sink.close(); _channel = null; } diff --git a/webrtc_controller_flutter/lib/webrtc/video_recorder.dart b/webrtc_controller_flutter/lib/webrtc/video_recorder.dart index 53a54b1..dc21dcf 100644 --- a/webrtc_controller_flutter/lib/webrtc/video_recorder.dart +++ b/webrtc_controller_flutter/lib/webrtc/video_recorder.dart @@ -10,8 +10,8 @@ import 'package:path_provider/path_provider.dart'; /// 无需自行触碰原生 VideoSink。 /// /// 录制文件保存位置: -/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限) -/// - iOS:/Documents/WebRTCRecordings/ +/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限) +/// - iOS:<App>/Documents/WebRTCRecordings/ class VideoRecorder { MediaRecorder? _recorder; bool _recording = false; diff --git a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart index 0d16ee4..3908ef1 100644 --- a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart +++ b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart @@ -88,6 +88,9 @@ class WebRtcController { static const int streamModeWebRtc = 0; static const int streamModeSelfCodec = 1; + /// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空则用 kIceServers)。 + static List>? iceServersOverride; + WebRtcController({ required this.signaling, required this.deviceId, @@ -100,8 +103,9 @@ class WebRtcController { Future initialize() async { await renderer.initialize(); + final iceServers = iceServersOverride ?? kIceServers; final configuration = { - 'iceServers': kIceServers, + 'iceServers': iceServers, 'sdpSemantics': 'unified-plan', 'iceCandidatePoolSize': 10, // 增强复杂网络下的稳定性,参考 Android 端配置。 @@ -110,7 +114,7 @@ class WebRtcController { 'tcpCandidatePolicy': 'enabled', }; - final iceUrls = (kIceServers) + final iceUrls = iceServers .map((e) => '${e['username'] != null ? '(${e['username']})' : ''}${e['urls']}') .join(', '); debugPrint('[WebRtcController] signaling server: ${signaling.serverUrl} | ' diff --git a/webrtc_controller_flutter/linux/flutter/generated_plugin_registrant.cc b/webrtc_controller_flutter/linux/flutter/generated_plugin_registrant.cc index 3f48831..9422aff 100644 --- a/webrtc_controller_flutter/linux/flutter/generated_plugin_registrant.cc +++ b/webrtc_controller_flutter/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); diff --git a/webrtc_controller_flutter/linux/flutter/generated_plugins.cmake b/webrtc_controller_flutter/linux/flutter/generated_plugins.cmake index 53574ba..c35c8e6 100644 --- a/webrtc_controller_flutter/linux/flutter/generated_plugins.cmake +++ b/webrtc_controller_flutter/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux flutter_webrtc ) diff --git a/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 774d11e..e74479b 100644 --- a/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/webrtc_controller_flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import device_info_plus +import flutter_secure_storage_macos import flutter_webrtc func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) } diff --git a/webrtc_controller_flutter/pubspec.lock b/webrtc_controller_flutter/pubspec.lock index 2e70be5..0d32142 100644 --- a/webrtc_controller_flutter/pubspec.lock +++ b/webrtc_controller_flutter/pubspec.lock @@ -150,6 +150,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -176,6 +224,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" jni: dependency: transitive description: @@ -196,10 +260,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.2" + version: "0.6.7" leak_tracker: dependency: transitive description: diff --git a/webrtc_controller_flutter/pubspec.yaml b/webrtc_controller_flutter/pubspec.yaml index 4e72a27..cb551d6 100644 --- a/webrtc_controller_flutter/pubspec.yaml +++ b/webrtc_controller_flutter/pubspec.yaml @@ -44,6 +44,12 @@ dependencies: # WebSocket 信令通信 web_socket_channel: ^3.0.3 + # HTTP API(登录 / 刷新 / 绑定列表 / TURN 凭证) + http: ^1.2.0 + + # 安全存储:refreshToken 存 Keychain / EncryptedSharedPreferences + flutter_secure_storage: ^9.0.0 + # 设备 ID 生成 uuid: ^4.4.0 diff --git a/webrtc_controller_flutter/windows/flutter/generated_plugin_registrant.cc b/webrtc_controller_flutter/windows/flutter/generated_plugin_registrant.cc index e8559e4..6b3a54a 100644 --- a/webrtc_controller_flutter/windows/flutter/generated_plugin_registrant.cc +++ b/webrtc_controller_flutter/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); } diff --git a/webrtc_controller_flutter/windows/flutter/generated_plugins.cmake b/webrtc_controller_flutter/windows/flutter/generated_plugins.cmake index f3cdbfe..7e20152 100644 --- a/webrtc_controller_flutter/windows/flutter/generated_plugins.cmake +++ b/webrtc_controller_flutter/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows flutter_webrtc ) diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj index 9c3061f..c458957 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj +++ b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; }; FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; }; FB0000000000000000000012 /* VideoRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000012 /* VideoRecorder.swift */; }; + FB0000000000000000000013 /* SetupPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000013 /* SetupPanelView.swift */; }; + FB0000000000000000000014 /* ControlPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000014 /* ControlPanelView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,6 +47,8 @@ FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA0000000000000000000011 /* web_rtc_controller_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = web_rtc_controller_ios.app; sourceTree = BUILT_PRODUCTS_DIR; }; FA0000000000000000000012 /* VideoRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRecorder.swift; sourceTree = ""; }; + FA0000000000000000000013 /* SetupPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPanelView.swift; sourceTree = ""; }; + FA0000000000000000000014 /* ControlPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanelView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -132,6 +136,8 @@ children = ( FA000000000000000000000B /* ContentView.swift */, FA000000000000000000000C /* AuthSheetView.swift */, + FA0000000000000000000013 /* SetupPanelView.swift */, + FA0000000000000000000014 /* ControlPanelView.swift */, FA0000000000000000000008 /* RemoteTouchView.swift */, FA0000000000000000000009 /* RemoteVideoView.swift */, FA000000000000000000000A /* SelfCodecDisplayView.swift */, @@ -239,6 +245,8 @@ FB0000000000000000000006 /* WebRTCClient.swift in Sources */, FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */, FB0000000000000000000012 /* VideoRecorder.swift in Sources */, + FB0000000000000000000013 /* SetupPanelView.swift in Sources */, + FB0000000000000000000014 /* ControlPanelView.swift in Sources */, FB0000000000000000000008 /* RemoteTouchView.swift in Sources */, FB0000000000000000000009 /* RemoteVideoView.swift in Sources */, FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */, diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift index 1d510ca..5bc20de 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift @@ -5,17 +5,22 @@ protocol SignalingClientDelegate: AnyObject { func signalingDidDisconnect() func signaling(didFail error: String) func signaling(didReceive message: SignalMessage) + /// 令牌失效(关闭码 4001):需刷新令牌后重连。 + func signalingTokenExpired() + /// 强制下线(关闭码 4003):需停止重连并跳登录。 + func signalingForceLogout() } /// 信令 WebSocket 客户端(URLSessionWebSocketTask 实现)。 -/// 连接成功后自动发送 REGISTER(deviceType = CONTROLLER), -/// 所有回调均已切换到主线程。 +/// +/// 鉴权方式:通过 URLRequest 在握手请求头携带 `Authorization: Bearer `。 +/// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。 final class SignalingClient: NSObject { weak var delegate: SignalingClientDelegate? private let serverUrl: String - private let deviceId: String + private let token: String? private var session: URLSession? private var task: URLSessionWebSocketTask? private var manuallyClosed = false @@ -23,10 +28,12 @@ final class SignalingClient: NSObject { private var isOpened = false /// 是否已上报过断开/失败,避免重复回调 private var didNotifyClosure = false + /// 心跳定时器(每 25s 发送 PING) + private var heartbeatTimer: Timer? - init(serverUrl: String, deviceId: String) { + init(serverUrl: String, token: String?) { self.serverUrl = serverUrl - self.deviceId = deviceId + self.token = token super.init() } @@ -38,10 +45,17 @@ final class SignalingClient: NSObject { manuallyClosed = false isOpened = false didNotifyClosure = false + + // 握手携带 Bearer token(URLSessionWebSocketTask 不直接支持自定义头,需用 URLRequest)。 + var request = URLRequest(url: url) + if let token, !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - task = session?.webSocketTask(with: url) + task = session?.webSocketTask(with: request) // 接收循环在 didOpenWithProtocol(握手完成)后再启动, // 避免在 socket 未真正连接时调用 receive 触发 Code 57。 task?.resume() @@ -50,6 +64,7 @@ final class SignalingClient: NSObject { func disconnect() { manuallyClosed = true isOpened = false + stopHeartbeat() task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8)) task = nil session?.invalidateAndCancel() @@ -71,12 +86,19 @@ final class SignalingClient: NSObject { // MARK: - Private - private func registerDevice() { - var msg = SignalMessage() - msg.type = "REGISTER" - msg.fromDeviceId = deviceId - msg.deviceType = "CONTROLLER" - send(msg) + private func startHeartbeat() { + stopHeartbeat() + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in + guard let self, let task = self.task else { return } + task.send(.string("{\"type\":\"PING\"}")) { error in + if let error { NSLog("[Signaling] ping error: \(error.localizedDescription)") } + } + } + } + + private func stopHeartbeat() { + heartbeatTimer?.invalidate() + heartbeatTimer = nil } private func receiveLoop() { @@ -99,24 +121,20 @@ final class SignalingClient: NSObject { } /// 处理接收失败:区分"正常断开"与"连接失败"。 - /// Socket 已断开(Code 57 等)视为断开而非致命错误。 private func handleReceiveFailure(_ error: Error) { guard !manuallyClosed, !didNotifyClosure else { return } didNotifyClosure = true let nsError = error as NSError - // NSPOSIXErrorDomain Code 57: Socket is not connected(连接已断开) let isDisconnect = (nsError.domain == NSPOSIXErrorDomain && nsError.code == 57) || (nsError.domain == NSURLErrorDomain && (nsError.code == NSURLErrorNetworkConnectionLost - || nsError.code == NSURLErrorCancelled)) + || nsError.code == NSURLErrorCancelled)) DispatchQueue.main.async { if self.isOpened || isDisconnect { - // 连接已建立过后再断开,按"断开"处理 self.delegate?.signalingDidDisconnect() } else { - // 从未成功建立连接,按"连接失败"处理 self.delegate?.signaling(didFail: error.localizedDescription) } } @@ -137,9 +155,10 @@ extension SignalingClient: URLSessionWebSocketDelegate { webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { isOpened = true - // 握手完成后再启动接收循环,避免 socket 未连接时 receive 报错 + // 握手完成后再启动接收循环,避免 socket 未连接时 receive 报错。 + // 不再发送 REGISTER,服务端根据 Bearer 令牌自动注册。 receiveLoop() - registerDevice() + startHeartbeat() DispatchQueue.main.async { self.delegate?.signalingDidConnect() } @@ -150,8 +169,19 @@ extension SignalingClient: URLSessionWebSocketDelegate { didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { isOpened = false + stopHeartbeat() guard !manuallyClosed, !didNotifyClosure else { return } didNotifyClosure = true + + let code = Int(closeCode.rawValue) + if code == 4001 { + DispatchQueue.main.async { self.delegate?.signalingTokenExpired() } + return + } + if code == 4003 { + DispatchQueue.main.async { self.delegate?.signalingForceLogout() } + return + } DispatchQueue.main.async { self.delegate?.signalingDidDisconnect() } diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift new file mode 100644 index 0000000..9cbb086 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Utils/ApiClient.swift @@ -0,0 +1,94 @@ +import Foundation + +/// 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。 +/// +/// - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀); +/// - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken); +/// - verify() → 校验 accessToken 是否仍有效; +/// - bindings() → 本机可连接的被控端列表(仅已绑定设备); +/// - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。 +struct ApiClient { + + /// 服务端 HTTP 基址(与信令同源)。部署时通过 Build Setting / Info.plist 注入。 + static var baseURL: String { + // 优先读取 Info.plist 中的 APIBase,缺省回退到信号服务器同源 HTTPS。 + if let v = Bundle.main.object(forInfoDictionaryKey: "APIBase") as? String, !v.isEmpty { + return v + } + return "https://www.ttstd.com" + } + + /// 登录:成功返回 accessToken / refreshToken / expireAt。 + static func login(username: String, password: String) async throws -> [String: Any] { + let body: [String: Any] = ["username": username, "password": password] + return try await post("/api/auth/login", body: body) + } + + /// 刷新令牌:body 需带 refreshToken;成功返回新的 accessToken(可能轮换 refreshToken)。 + static func refresh(refreshToken: String) async throws -> [String: Any] { + let body: [String: Any] = ["refreshToken": refreshToken] + return try await post("/api/auth/refresh", body: body) + } + + /// 校验当前 accessToken 是否有效。 + static func verify(accessToken: String) async throws -> [String: Any] { + return try await get("/api/client/verify", accessToken: accessToken) + } + + /// 拉取本机可连接的被控端绑定列表(仅已绑定设备)。 + static func bindings(accessToken: String) async throws -> [String: Any] { + return try await get("/api/client/bindings", accessToken: accessToken) + } + + /// 拉取 TURN 短期凭证(服务端未开启时抛错,调用方忽略)。 + static func turnCredentials(accessToken: String) async throws -> [String: Any] { + return try await get("/api/client/turn-credentials", accessToken: accessToken) + } + + // MARK: - Private + + private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] { + var req = URLRequest(url: URL(string: baseURL + path)!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + return try await perform(req) + } + + private static func get(_ path: String, accessToken: String) async throws -> [String: Any] { + var req = URLRequest(url: URL(string: baseURL + path)!) + req.httpMethod = "GET" + req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + return try await perform(req) + } + + private static func perform(_ request: URLRequest) async throws -> [String: Any] { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ApiError(message: "无效响应") + } + if http.statusCode == 401 { + throw ApiError(code: "UNAUTHORIZED", message: "令牌失效", httpCode: 401) + } + guard (200..<300).contains(http.statusCode) else { + let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String + ?? "HTTP \(http.statusCode)" + throw ApiError(message: msg) + } + return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } +} + +struct ApiError: Error { + let code: String + let message: String + let httpCode: Int + + init(code: String = "ERROR", message: String, httpCode: Int = -1) { + self.code = code + self.message = message + self.httpCode = httpCode + } + + var localizedDescription: String { message } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Utils/TokenStore.swift b/webrtc_controller_ios/web_rtc_controller_ios/Utils/TokenStore.swift new file mode 100644 index 0000000..2fd85c2 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Utils/TokenStore.swift @@ -0,0 +1,81 @@ +import Foundation +import Security + +/// 主控端令牌安全存储(Keychain)。 +/// +/// refreshToken 为一次性(ses_ 前缀),安全要求高,存 Keychain; +/// accessToken 仅内存持有(掉线即失),这里不持久化。 +enum TokenStore { + + private static let service = "com.ttstd.webrtccontroller" + private static let accessKey = "access_token" + private static let refreshKey = "refresh_token" + private static let userKey = "username" + + // MARK: - Access Token(内存 + Keychain 双重保留,便于冷启动恢复) + + static func saveAccessToken(_ token: String) { + save(key: accessKey, value: token) + } + + static func loadAccessToken() -> String? { + load(key: accessKey) + } + + // MARK: - Refresh Token(一次性,必须持久化于 Keychain) + + static func saveRefreshToken(_ token: String) { + save(key: refreshKey, value: token) + } + + static func loadRefreshToken() -> String? { + load(key: refreshKey) + } + + static func saveUsername(_ name: String) { + save(key: userKey, value: name) + } + + static func loadUsername() -> String? { + load(key: userKey) + } + + static func clear() { + for k in [accessKey, refreshKey, userKey] { + let query: [String: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: k + ] + SecItemDelete(query as CFDictionary) + } + } + + // MARK: - Private + + private static func save(key: String, value: String) { + // 先删后存,避免重复条目。 + let query: [String: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + kSecValueData: Data(value.utf8) + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + private static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift index 17d7088..cdcec03 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift @@ -72,7 +72,18 @@ final class ControllerViewModel: NSObject, ObservableObject { /// 最近一次录制保存的文件路径(沙盒内) @Published var lastRecordingPath: String? - let myDeviceId: String = DeviceUtils.deviceId() + /// 本机设备ID:初始为空,连接后由服务端 REGISTER_SUCCESS 下发(fromDeviceId)。 + @Published var myDeviceId: String = "" + + // MARK: - 登录态 + + @Published var isLoggedIn: Bool = false + @Published var username: String = "" + @Published var loginError: String? + @Published var isLoggingIn: Bool = false + + /// 当前 accessToken(Bearer 握手用),内存持有;refreshToken 存 Keychain。 + private var accessToken: String? // MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入) @@ -102,6 +113,7 @@ final class ControllerViewModel: NSObject, ObservableObject { override init() { super.init() remoteVideoView.delegate = self + restoreLoginState() selfCodecDecoder.displayLayer = selfCodecView.sampleBufferLayer selfCodecDecoder.onResolutionUpdate = { [weak self] w, h in guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return } @@ -129,7 +141,7 @@ final class ControllerViewModel: NSObject, ObservableObject { // MARK: - 连接 / 断开 - /// 用户点击"连接":弹出鉴权选择 + /// 用户点击"连接":先确保已登录(Bearer token),再弹出鉴权选择 func requestConnect() { let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else { @@ -140,10 +152,14 @@ final class ControllerViewModel: NSObject, ObservableObject { alertMessage = "请输入目标设备 ID" return } + guard isLoggedIn else { + alertMessage = "请先在右上角登录账号" + return + } showAuthSheet = true } - /// 鉴权弹窗确认后开始连接 + /// 鉴权弹窗确认后开始连接(已确保 accessToken 有效)。 func connect(authType: AuthType, authValue: String) { showAuthSheet = false pendingAuthType = authType @@ -153,7 +169,7 @@ final class ControllerViewModel: NSObject, ObservableObject { let signaling = SignalingClient( serverUrl: serverUrl.trimmingCharacters(in: .whitespaces), - deviceId: myDeviceId) + token: accessToken) signaling.delegate = self signalingClient = signaling signaling.connect() @@ -277,6 +293,10 @@ final class ControllerViewModel: NSObject, ObservableObject { fileprivate func startWebRTC() { guard let signaling = signalingClient else { return } + guard !myDeviceId.isEmpty else { + failConnection("未获取到本机设备ID,连接中止") + return + } statusText = "正在建立 WebRTC 连接..." let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId) client.delegate = self @@ -295,6 +315,141 @@ final class ControllerViewModel: NSObject, ObservableObject { disconnect() } + // MARK: - 登录 / 令牌 + + /// 应用启动时从 Keychain 恢复登录态(accessToken / refreshToken)。 + private func restoreLoginState() { + let at = TokenStore.loadAccessToken() + let rt = TokenStore.loadRefreshToken() + if at != nil, rt != nil { + accessToken = at + username = TokenStore.loadUsername() ?? "" + isLoggedIn = true + } + } + + /// 登录:调用 /api/auth/login,保存令牌并恢复登录态。 + func login(username: String, password: String) async { + isLoggingIn = true + loginError = nil + do { + let data = try await ApiClient.login(username: username, password: password) + guard let at = data["accessToken"] as? String, + let rt = data["refreshToken"] as? String else { + throw ApiError(message: "登录返回缺失") + } + TokenStore.saveAccessToken(at) + TokenStore.saveRefreshToken(rt) + TokenStore.saveUsername(username) + accessToken = at + self.username = username + isLoggedIn = true + } catch { + loginError = (error as? ApiError)?.message ?? error.localizedDescription + } + isLoggingIn = false + } + + /// 退出登录:清空令牌与状态。 + func logout() { + TokenStore.clear() + accessToken = nil + username = "" + isLoggedIn = false + if isConnecting || isControlling { + disconnect() + } + } + + /// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。 + private func ensureAccessToken() async throws -> String { + if let existing = accessToken { + do { + _ = try await ApiClient.verify(accessToken: existing) + return existing + } catch { + // 校验失败可能是过期,走刷新。 + } + } + guard let rt = TokenStore.loadRefreshToken() else { + throw ApiError(message: "无 refreshToken,请重新登录") + } + let data = try await ApiClient.refresh(refreshToken: rt) + guard let newAt = data["accessToken"] as? String else { + throw ApiError(message: "刷新失败") + } + TokenStore.saveAccessToken(newAt) + if let newRt = data["refreshToken"] as? String { + TokenStore.saveRefreshToken(newRt) + } + accessToken = newAt + return newAt + } + + /// 令牌失效后刷新并重连。 + private func refreshAndReconnect() async { + do { + let newAt = try await ensureAccessToken() + accessToken = newAt + DispatchQueue.main.async { + self.signalingClient?.disconnect() + self.signalingClient = nil + // 用新令牌重建连接(REGISTER_SUCCESS 会再次触发 startWebRTC)。 + let signaling = SignalingClient( + serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces), + token: newAt) + signaling.delegate = self + self.signalingClient = signaling + signaling.connect() + } + } catch { + DispatchQueue.main.async { + self.logout() + self.alertMessage = "令牌刷新失败,请重新登录" + } + } + } + + /// 拉取本机可连接的被控端绑定列表(仅已绑定设备),用于提示。 + private func loadBindings() { + guard let at = accessToken else { return } + Task { + do { + let data = try await ApiClient.bindings(accessToken: at) + guard let list = data["bindings"] as? [[String: Any]], !list.isEmpty else { return } + let uids = list.compactMap { $ -> String? in + if let u = $["deviceUid"] as? String, !u.isEmpty { return u } + return $["deviceId"] as? String + }.filter { !$0.isEmpty } + if !uids.isEmpty { + DispatchQueue.main.async { + self.alertMessage = "已绑定设备: " + uids.joined(separator: ", ") + } + } + } catch { /* 忽略 */ } + } + } + + /// 拉取 TURN 短期凭证,覆盖默认 ICE(由 WebRTCClient 读取)。 + private func loadTurnCredentials() async { + guard let at = accessToken else { return } + do { + let data = try await ApiClient.turnCredentials(accessToken: at) + guard let servers = data["iceServers"] as? [[String: Any]], !servers.isEmpty else { return } + var mapped: [[String: Any]] = [] + for s in servers { + var entry: [String: Any] = [:] + if let urls = s["urls"] as? String { entry["urls"] = urls } + if let user = s["username"] as? String { entry["username"] = user } + if let cred = s["credential"] as? String { entry["credential"] = cred } + mapped.append(entry) + } + if !mapped.isEmpty { + WebRTCClient.iceServersOverride = mapped + } + } catch { /* 服务端未开启 TURN,忽略 */ } + } + // MARK: - 统计 private func startStatsTimer() { @@ -431,8 +586,7 @@ final class ControllerViewModel: NSObject, ObservableObject { extension ControllerViewModel: SignalingClientDelegate { func signalingDidConnect() { - statusText = "信令已连接,正在发起会话..." - startWebRTC() + statusText = "信令已连接,等待注册..." } func signalingDidDisconnect() { @@ -445,7 +599,30 @@ extension ControllerViewModel: SignalingClientDelegate { failConnection("信令连接失败: \(error)") } + /// 令牌失效(关闭码 4001):刷新令牌后重连。 + func signalingTokenExpired() { + Task { await refreshAndReconnect() } + } + + /// 强制下线(关闭码 4003):清空登录态,跳回登录。 + func signalingForceLogout() { + DispatchQueue.main.async { + self.logout() + self.alertMessage = "账号已在其他位置登录,已强制下线" + } + } + func signaling(didReceive message: SignalMessage) { + // 服务端下发本机 deviceId(CONTROLLER),随后发起 WebRTC Offer。 + if (message.type ?? "").uppercased() == "REGISTER_SUCCESS", + let from = message.fromDeviceId, !from.isEmpty { + myDeviceId = from + statusText = "已注册 (\(from)),正在发起会话..." + startWebRTC() + loadBindings() + Task { await loadTurnCredentials() } + return + } switch (message.type ?? "").uppercased() { case "ANSWER": if let sdp = message.payloadJSON()?["sdp"] as? String { diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift index cbf6972..3e318d2 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift @@ -6,7 +6,9 @@ struct ContentView: View { var body: some View { ZStack { - if viewModel.isControlling { + if !viewModel.isLoggedIn { + LoginView(viewModel: viewModel) + } else if viewModel.isControlling { ControlPanelView(viewModel: viewModel) } else { SetupPanelView(viewModel: viewModel) @@ -26,311 +28,3 @@ struct ContentView: View { } } } - -// MARK: - 设置面板 - -private struct SetupPanelView: View { - @ObservedObject var viewModel: ControllerViewModel - - var body: some View { - NavigationView { - Form { - Section("本机信息") { - HStack { - Text("本机设备 ID") - Spacer() - Text(viewModel.myDeviceId) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.secondary) - .textSelection(.enabled) - } - } - - Section("连接设置") { - TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl) - .keyboardType(.URL) - .autocapitalization(.none) - .disableAutocorrection(true) - TextField("目标设备 ID", text: $viewModel.targetDeviceId) - .autocapitalization(.allCharacters) - .disableAutocorrection(true) - } - - Section { - Button { - viewModel.requestConnect() - } label: { - HStack { - Spacer() - if viewModel.isConnecting { - ProgressView() - .padding(.trailing, 8) - } - Text(viewModel.isConnecting ? "连接中..." : "连接") - .font(.system(size: 15, weight: .semibold)) - Spacer() - } - } - .disabled(viewModel.isConnecting) - - if viewModel.isConnecting { - Button(role: .destructive) { - viewModel.disconnect() - } label: { - HStack { - Spacer() - Text("取消") - Spacer() - } - } - } - } footer: { - Text(viewModel.statusText) - } - } - .navigationTitle("WebRTC 控制端") - } - .navigationViewStyle(.stack) - } -} - -// MARK: - 控制面板 - -private struct ControlPanelView: View { - @ObservedObject var viewModel: ControllerViewModel - - var body: some View { - VStack(spacing: 0) { - statsPanel - topBar - videoArea - navButtons - } - .background(Color.black.ignoresSafeArea()) - } - - /// 统计信息面板:置于最上方,点击可展开/收起详情 - private var statsPanel: some View { - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.statsExpanded.toggle() - } - } label: { - HStack(spacing: 8) { - Image(systemName: "chart.bar.xaxis") - .font(.system(size: 11)) - Text(statsSummary) - .font(.system(size: 11, design: .monospaced)) - .lineLimit(1) - .truncationMode(.tail) - Spacer() - Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: 11, weight: .semibold)) - } - .foregroundColor(Color(white: 0.8)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if viewModel.statsExpanded { - Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(Color(white: 0.75)) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 6) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color(white: 0.08)) - } - - /// 概要行:取统计信息首行内容,收起时显示 - private var statsSummary: String { - if let first = viewModel.statsText.split(separator: "\n").first { - return String(first) - } - return "统计信息" - } - - private var topBar: some View { - VStack(spacing: 6) { - // 第一行:状态 + 串流模式切换 + 分辨率 + 帧率 - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - Text(viewModel.statusText) - .font(.footnote) - .foregroundColor(.green) - .lineLimit(1) - .fixedSize() - - // 串流模式切换(WebRTC / 自编码) - Toggle(isOn: Binding( - get: { viewModel.streamMode == .selfCodec }, - set: { viewModel.toggleStreamMode($0) })) { - Text("自编码") - .font(.footnote) - .foregroundColor(.white) - } - .toggleStyle(.switch) - .fixedSize() - - // 分辨率选择 - Menu { - ForEach(ResolutionOption.all) { option in - Button { - viewModel.selectResolution(option) - } label: { - if option == viewModel.selectedResolution { - Label(option.title, systemImage: "checkmark") - } else { - Text(option.title) - } - } - } - } label: { - Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical") - .font(.footnote) - .fixedSize() - } - - // 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报) - Menu { - ForEach(viewModel.fpsOptions, id: \.self) { fps in - Button { - viewModel.selectFps(fps) - } label: { - if fps == viewModel.currentFps { - Label("\(fps)fps", systemImage: "checkmark") - } else { - Text("\(fps)fps") - } - } - } - } label: { - Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率", - systemImage: "speedometer") - .font(.footnote) - .fixedSize() - } - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - - // 第二行:录制 + 断开(当前行下方) - HStack(spacing: 12) { - // 录制远端视频(MP4) - Button { - if viewModel.isRecording { - viewModel.stopRecording() - } else { - viewModel.startRecording() - } - } label: { - Label(viewModel.isRecording ? "停止" : "录制", - systemImage: viewModel.isRecording ? "stop.circle.fill" : "circle.circle") - .font(Font.footnote.weight(.semibold)) - .fixedSize() - } - .buttonStyle(.borderedProminent) - .tint(viewModel.isRecording ? .red : .orange) - .controlSize(.small) - - Button(role: .destructive) { - viewModel.disconnect() - } label: { - Text("断开") - .font(Font.footnote.weight(.semibold)) - .fixedSize() - } - .buttonStyle(.borderedProminent) - .tint(.red) - .controlSize(.small) - - Spacer() - } - .padding(.horizontal, 12) - .padding(.bottom, 6) - } - .background(Color(white: 0.1)) - } - - /// 视频区域:按远端画面宽高比自适应,触控层与画面精确对齐 - private var videoArea: some View { - GeometryReader { _ in - ZStack { - Color.black - ZStack { - // WebRTC 视频渲染(UIKit RTCMTLVideoView) - RemoteVideoView(videoView: viewModel.remoteVideoView) - .opacity(viewModel.streamMode == .webrtc ? 1 : 0) - // 自编码 H.264 硬解渲染(UIKit AVSampleBufferDisplayLayer) - SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView) - .opacity(viewModel.streamMode == .selfCodec ? 1 : 0) - // UIKit 触控捕获层 - TouchOverlay( - onMotionEvent: { action, x, y in - viewModel.sendMotionEvent(action: action, x: x, y: y) - }, - onSwipe: { x1, y1, x2, y2, duration in - viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration) - }) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - // 录制中指示(左上角红点) - if viewModel.isRecording { - VStack { - HStack(spacing: 6) { - Circle() - .fill(Color.red) - .frame(width: 10, height: 10) - Text("REC") - .font(Font.caption2.weight(.bold)) - .foregroundColor(.white) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.black.opacity(0.5)) - .cornerRadius(6) - Spacer() - } - .padding(8) - } - } - } - } - - private var navButtons: some View { - HStack(spacing: 24) { - navButton(title: "返回", systemImage: "arrow.uturn.backward") { - viewModel.sendKey(ControllerViewModel.AndroidKey.back) - } - navButton(title: "主页", systemImage: "circle") { - viewModel.sendKey(ControllerViewModel.AndroidKey.home) - } - navButton(title: "多任务", systemImage: "square.on.square") { - viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch) - } - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color(white: 0.1)) - } - - private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - VStack(spacing: 2) { - Image(systemName: systemImage) - .font(.system(size: 18)) - Text(title) - .font(.caption2) - } - .foregroundColor(.white) - .frame(width: 64) - } - } -} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/ControlPanelView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/ControlPanelView.swift new file mode 100644 index 0000000..9115a9e --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/ControlPanelView.swift @@ -0,0 +1,241 @@ +import SwiftUI + +/// 控制面板:连接成功后显示,承载远端画面、统计信息与控制操作。 +struct ControlPanelView: View { + @ObservedObject var viewModel: ControllerViewModel + + var body: some View { + VStack(spacing: 0) { + statsPanel + topBar + videoArea + navButtons + } + .background(Color.black.ignoresSafeArea()) + } + + /// 统计信息面板:置于最上方,点击可展开/收起详情 + private var statsPanel: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.statsExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "chart.bar.xaxis") + .font(.system(size: 11)) + Text(statsSummary) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundColor(Color(white: 0.8)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if viewModel.statsExpanded { + Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(white: 0.75)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(white: 0.08)) + } + + /// 概要行:取统计信息首行内容,收起时显示 + private var statsSummary: String { + if let first = viewModel.statsText.split(separator: "\n").first { + return String(first) + } + return "统计信息" + } + + private var topBar: some View { + VStack(spacing: 6) { + // 第一行:状态 + 串流模式切换 + 分辨率 + 帧率 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + Text(viewModel.statusText) + .font(.footnote) + .foregroundColor(.green) + .lineLimit(1) + .fixedSize() + + // 串流模式切换(WebRTC / 自编码) + Toggle(isOn: Binding( + get: { viewModel.streamMode == .selfCodec }, + set: { viewModel.toggleStreamMode($0) })) { + Text("自编码") + .font(.footnote) + .foregroundColor(.white) + } + .toggleStyle(.switch) + .fixedSize() + + // 分辨率选择 + Menu { + ForEach(ResolutionOption.all) { option in + Button { + viewModel.selectResolution(option) + } label: { + if option == viewModel.selectedResolution { + Label(option.title, systemImage: "checkmark") + } else { + Text(option.title) + } + } + } + } label: { + Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical") + .font(.footnote) + .fixedSize() + } + + // 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报) + Menu { + ForEach(viewModel.fpsOptions, id: \.self) { fps in + Button { + viewModel.selectFps(fps) + } label: { + if fps == viewModel.currentFps { + Label("\(fps)fps", systemImage: "checkmark") + } else { + Text("\(fps)fps") + } + } + } + } label: { + Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率", + systemImage: "speedometer") + .font(.footnote) + .fixedSize() + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + // 第二行:录制 + 断开(当前行下方) + HStack(spacing: 12) { + // 录制远端视频(MP4) + Button { + if viewModel.isRecording { + viewModel.stopRecording() + } else { + viewModel.startRecording() + } + } label: { + Label(viewModel.isRecording ? "停止" : "录制", + systemImage: viewModel.isRecording ? "stop.circle.fill" : "circle.circle") + .font(Font.footnote.weight(.semibold)) + .fixedSize() + } + .buttonStyle(.borderedProminent) + .tint(viewModel.isRecording ? .red : .orange) + .controlSize(.small) + + Button(role: .destructive) { + viewModel.disconnect() + } label: { + Text("断开") + .font(Font.footnote.weight(.semibold)) + .fixedSize() + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.small) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + .background(Color(white: 0.1)) + } + + /// 视频区域:按远端画面宽高比自适应,触控层与画面精确对齐 + private var videoArea: some View { + GeometryReader { _ in + ZStack { + Color.black + ZStack { + // WebRTC 视频渲染(UIKit RTCMTLVideoView) + RemoteVideoView(videoView: viewModel.remoteVideoView) + .opacity(viewModel.streamMode == .webrtc ? 1 : 0) + // 自编码 H.264 硬解渲染(UIKit AVSampleBufferDisplayLayer) + SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView) + .opacity(viewModel.streamMode == .selfCodec ? 1 : 0) + // UIKit 触控捕获层 + TouchOverlay( + onMotionEvent: { action, x, y in + viewModel.sendMotionEvent(action: action, x: x, y: y) + }, + onSwipe: { x1, y1, x2, y2, duration in + viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration) + }) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // 录制中指示(左上角红点) + if viewModel.isRecording { + VStack { + HStack(spacing: 6) { + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + Text("REC") + .font(Font.caption2.weight(.bold)) + .foregroundColor(.white) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.5)) + .cornerRadius(6) + Spacer() + } + .padding(8) + } + } + } + } + + private var navButtons: some View { + HStack(spacing: 24) { + navButton(title: "返回", systemImage: "arrow.uturn.backward") { + viewModel.sendKey(ControllerViewModel.AndroidKey.back) + } + navButton(title: "主页", systemImage: "circle") { + viewModel.sendKey(ControllerViewModel.AndroidKey.home) + } + navButton(title: "多任务", systemImage: "square.on.square") { + viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch) + } + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color(white: 0.1)) + } + + private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 2) { + Image(systemName: systemImage) + .font(.system(size: 18)) + Text(title) + .font(.caption2) + } + .foregroundColor(.white) + .frame(width: 64) + } + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/LoginView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/LoginView.swift new file mode 100644 index 0000000..087d7b8 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/LoginView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// 登录界面:输入用户名/密码,调用 ViewModel.login 保存令牌后进入主界面。 +struct LoginView: View { + @ObservedObject var viewModel: ControllerViewModel + + @State private var username: String = "" + @State private var password: String = "" + + var body: some View { + NavigationView { + Form { + Section("账号登录") { + TextField("用户名", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("密码", text: $password) + } + + if let err = viewModel.loginError, !err.isEmpty { + Section { + Text(err).foregroundColor(.red) + } + } + + Section { + Button { + Task { + await viewModel.login(username: username, password: password) + } + } label: { + HStack { + Spacer() + if viewModel.isLoggingIn { + ProgressView().padding(.trailing, 8) + } + Text(viewModel.isLoggingIn ? "登录中..." : "登录") + .font(.system(size: 15, weight: .semibold)) + Spacer() + } + } + .disabled(viewModel.isLoggingIn || username.isEmpty || password.isEmpty) + } footer: { + Text("登录后才能连接信令服务器并发起远程控制。账号由服务端统一创建与管控。") + } + } + .navigationTitle("WebRTC 控制端") + } + .navigationViewStyle(.stack) + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift new file mode 100644 index 0000000..7bd123f --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/SetupPanelView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// 设置面板:未连接时显示,用于查看本机设备 ID、配置信令服务器与连接目标。 +struct SetupPanelView: View { + @ObservedObject var viewModel: ControllerViewModel + + var body: some View { + NavigationView { + Form { + Section("本机信息") { + HStack { + Text("本机设备 ID") + Spacer() + Text(viewModel.myDeviceId) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + } + + Section("连接设置") { + TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + TextField("目标设备 ID", text: $viewModel.targetDeviceId) + .autocapitalization(.allCharacters) + .disableAutocorrection(true) + } + + Section { + Button { + viewModel.requestConnect() + } label: { + HStack { + Spacer() + if viewModel.isConnecting { + ProgressView() + .padding(.trailing, 8) + } + Text(viewModel.isConnecting ? "连接中..." : "连接") + .font(.system(size: 15, weight: .semibold)) + Spacer() + } + } + .disabled(viewModel.isConnecting) + + if viewModel.isConnecting { + Button(role: .destructive) { + viewModel.disconnect() + } label: { + HStack { + Spacer() + Text("取消") + Spacer() + } + } + } + } footer: { + Text(viewModel.statusText) + } + } + .navigationTitle("WebRTC 控制端") + } + .navigationViewStyle(.stack) + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift index f329e63..6138f4f 100644 --- a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift +++ b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift @@ -56,6 +56,9 @@ final class WebRTCClient: NSObject { super.init() } + /// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空时用内置 STUN/TURN)。 + static var iceServersOverride: [[String: Any]]? + var isDataChannelOpen: Bool { controlChannel?.readyState == .open } @@ -71,13 +74,23 @@ final class WebRTCClient: NSObject { self.remoteRenderer = renderer let config = RTCConfiguration() - config.iceServers = [ - RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]), - RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]), - RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"], - username: "ttstd", - credential: "ttstd123") - ] + if let override = WebRTCClient.iceServersOverride { + // 服务端下发的 TURN 短期凭证(RFC 7635)。 + config.iceServers = override.compactMap { entry in + guard let urls = entry["urls"] as? String else { return nil } + return RTCIceServer(urlStrings: [urls], + username: entry["username"] as? String, + credential: entry["credential"] as? String) + } + } else { + config.iceServers = [ + RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]), + RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]), + RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"], + username: "ttstd", + credential: "ttstd123") + ] + } config.sdpSemantics = .unifiedPlan config.continualGatheringPolicy = .gatherContinually config.iceCandidatePoolSize = 10