[fix] preserve join authentication compatibility for released clients

This commit is contained in:
dijunkun committed 2026-10-10 17:00:50 +08:00
1 parent bd553897dd
commit 87255024f0
4 files changed
+51 -15

No files matched your search

+6 -2
View File
@@ -264,9 +264,13 @@ sudo docker compose up -d --no-build
同一 WebSocket 连接只允许一次成功登录。后续 `login` 请求返回 `Already authenticated`,不会再注册设备、验证密码或更新在线状态;如需重新登录,请建立新连接。首次登录失败后仍可在原连接重试。
`query_user_id_list` 和 `join_transmission` 要求连接先完成登录,加入会话时还校验请求者身份与连接绑定。设备密码错误和设备 ID 不存在均返回 `Authentication failed`,不会返回设备列表或连接凭据。
`query_user_id_list` 和 `join_transmission` 要求连接先完成登录,加入会话时还校验请求者身份与连接绑定。设备密码错误和设备 ID 不存在使用相同回复,不会返回设备列表或连接凭据。登录和查询保留 `Authentication failed`;Join 为兼容已发布桌面端,统一使用 `reason: "Incorrect password"` 和 `error_code: "AUTHENTICATION_FAILED"`,不恢复可区分 ID 是否存在的报错。
设备登录、查询和加入会话共用失败计数:15 分钟内,同一来源 IP 失败 20 次,或同一目标设备失败 5 次,将从达到阈值时起冷却 15 分钟。冷却期间跳过密码验证(包括正确密码),返回 `Too many authentication attempts` 和以秒计的 `retry_after`。重连、更换控制端 ID、切换接口或成功验证其他设备都不会清除失败计数;被限流的请求不延长冷却时间。已有远控会话不受影响。
旧桌面端的空密码 Join(`ID@`)返回 `Incorrect password` / `PASSWORD_REQUIRED` 以打开密码框;不查询设备是否存在、不进行密码验证,也不消耗密码计算或失败额度。此提示不授予任何会话权限,不清除或延长已有冷却。新客户端应先收集密码再 Join。
Join 的其他拒绝(冷却、认证服务繁忙、未登录、远端不可用)统一保留旧客户端可识别的 `reason: "Remote unavailable"`,使其结束等待;旧版界面会显示设备离线。新客户端优先读取 `error_code`:`AUTHENTICATION_THROTTLED`、`CREDENTIAL_SERVICE_BUSY`、`CREDENTIAL_RATE_LIMITED`、`NOT_AUTHENTICATED` 或 `REMOTE_UNAVAILABLE`,并在存在 `retry_after` 时显示剩余秒数、禁止提前重试。认证诊断按错误码计数,空密码提示不计为鉴权失败。
设备登录、查询和加入会话共用失败计数:15 分钟内,同一来源 IP 失败 20 次,或同一目标设备失败 5 次,将从达到阈值时起冷却 15 分钟。冷却期间跳过密码验证(包括正确密码),返回 `Too many authentication attempts`(Join 使用上述兼容文案)及 `error_code: "AUTHENTICATION_THROTTLED"` 和以秒计的 `retry_after`。重连、更换控制端 ID、切换接口或成功验证其他设备都不会清除失败计数;被限流的请求不延长冷却时间。已有远控会话不受影响。
来源使用 TCP 对端 IP(不含端口),不信任请求字段或 `X-Forwarded-For`。同一 NAT 或反向代理后的连接共用来源额度。计数保存在单个服务进程内,重启后清空;多实例部署需要在可信网关或共享存储中补充统一限流。来源和目标各最多保留 16,384 条记录,容量耗尽时拒绝新增验证键,直到过期记录释放空间。
+6 -2
View File
@@ -246,9 +246,13 @@ Six-character passwords still have limited strength: source budgets and the fail
Each WebSocket connection allows one successful login. Subsequent `login` requests return `Already authenticated` without registering devices, verifying passwords or updating presence. Open a new connection to log in again; an initial failed login can still be retried on the same connection.
`query_user_id_list` and `join_transmission` require a logged-in connection; joining also checks that the requester's identity matches its connection binding. Wrong passwords and unknown device IDs both return `Authentication failed`, without a device list or connection credentials.
`query_user_id_list` and `join_transmission` require a logged-in connection; joining also checks that the requester's identity matches its connection binding. Wrong passwords and unknown device IDs return the same failure, without a device list or connection credentials. Login and queries use `Authentication failed`; joins use the compatibility reasons below.
Device login, queries and joins share failure counts. Within a 15-minute window, 20 failures from one source IP or 5 failures against one target device start a 15-minute cooldown at the threshold. During cooldown, password verification is skipped even for correct passwords; responses include `Too many authentication attempts` and `retry_after` in seconds. Reconnecting, changing controller IDs, switching endpoints or successfully authenticating other devices does not clear failures. Blocked requests do not extend cooldown. Existing remote-control sessions remain active.
Device login, queries and joins share failure counts. Within a 15-minute window, 20 failures from one source IP or 5 failures against one target device start a 15-minute cooldown at the threshold. During cooldown, password verification is skipped even for correct passwords; responses include `Too many authentication attempts` (joins use the compatibility reason below), `error_code: "AUTHENTICATION_THROTTLED"` and `retry_after` in seconds. Reconnecting, changing controller IDs, switching endpoints or successfully authenticating other devices does not clear failures. Blocked requests do not extend cooldown. Existing remote-control sessions remain active.
Join failures preserve reasons recognized by released desktops: wrong passwords and unknown IDs both return `Incorrect password` with `error_code: "AUTHENTICATION_FAILED"`. An empty-password Join (`ID@`) returns `Incorrect password` / `PASSWORD_REQUIRED` without looking up the target, verifying a password or consuming credential/failure budgets. It grants no access and never clears or extends a cooldown. New clients should collect a password before joining.
Other Join rejections retain the legacy `Remote unavailable` reason so old clients stop waiting (their UI displays offline). New clients must prefer `error_code`: `AUTHENTICATION_THROTTLED`, `CREDENTIAL_SERVICE_BUSY`, `CREDENTIAL_RATE_LIMITED`, `NOT_AUTHENTICATED`, `REMOTE_UNAVAILABLE`, displaying and honoring `retry_after` when present. Authentication diagnostics count error codes; empty-password prompts are not authentication failures. Login and query failures retain their generic reason.
The source is the TCP peer IP without its port; request fields and `X-Forwarded-For` are not trusted. Connections behind one NAT or reverse proxy share a source quota. Counters are local to one server process and reset on restart; multiple instances need coordinated limits in a trusted gateway or shared store. Each dimension holds at most 16,384 records and rejects new verification keys at capacity until expired records free space.
+31 -7
View File
@@ -12,6 +12,21 @@
namespace {
void SetFailure(json& message, const std::string& code,
const std::string& reason) {
message["error_code"] = code;
message["reason"] = reason;
// Released MiniRTC clients only recognize these legacy join reasons.
// Keep unknown IDs indistinguishable from wrong passwords. New clients use
// error_code so cooldowns and busy responses are not mistaken for offline.
if (message.value("type", "") == "user_join_transmission") {
message["reason"] =
code == "AUTHENTICATION_FAILED" || code == "PASSWORD_REQUIRED"
? "Incorrect password"
: "Remote unavailable";
}
}
bool GetStringField(const json& j, const char* key, std::string& value) {
if (!j.contains(key) || !j[key].is_string()) {
return false;
@@ -156,7 +171,7 @@ void SignalNegotiation::RunCredentialWork(websocketpp::connection_hdl hdl,
};
if (dispatch) {
auto expired = [this, hdl, failure]() mutable {
failure["reason"] = "Credential service busy";
SetFailure(failure, "CREDENTIAL_SERVICE_BUSY", "Credential service busy");
failure["retry_after"] = 1;
send_msg_(hdl, failure);
};
@@ -165,7 +180,7 @@ void SignalNegotiation::RunCredentialWork(websocketpp::connection_hdl hdl,
}
const int delay = admit();
if (delay > 0) {
failure["reason"] = "Too many credential requests";
SetFailure(failure, "CREDENTIAL_RATE_LIMITED", "Too many credential requests");
failure["retry_after"] = delay;
send_msg_(hdl, failure);
} else {
@@ -177,7 +192,7 @@ void SignalNegotiation::AuthenticateDevice(
websocketpp::connection_hdl hdl, const std::string& source_address,
const std::string& device_id, const std::string& password, json failure,
const CredentialDispatch& dispatch, std::function<void()> on_verified) {
failure["reason"] = "Authentication failed";
SetFailure(failure, "AUTHENTICATION_FAILED", "Authentication failed");
if (!ValidDeviceCredentials(device_id, password)) {
send_msg_(hdl, failure);
return;
@@ -206,7 +221,8 @@ void SignalNegotiation::AuthenticateDevice(
on_verified();
} else {
if (result.retry_after > 0) {
failure["reason"] = "Too many authentication attempts";
SetFailure(failure, "AUTHENTICATION_THROTTLED",
"Too many authentication attempts");
failure["retry_after"] = result.retry_after;
}
send_msg_(hdl, failure);
@@ -617,7 +633,15 @@ bool SignalNegotiation::join_transmission(websocketpp::connection_hdl hdl,
{"transmission_id", transmission_id},
{"status", "failed"}};
if (hdl.expired() || transmission_manager_->GetUserId(hdl) != user_id) {
failure["reason"] = "Not authenticated";
SetFailure(failure, "NOT_AUTHENTICATED", "Not authenticated");
send_msg_(hdl, failure);
return true;
}
if (password.empty() && ValidDeviceCredentials(transmission_id, password)) {
// Older desktops probe with ID@ to open their password dialog. This is
// not a password guess: never read the target record, spend a credential
// worker slot, or consume a failure budget for it.
SetFailure(failure, "PASSWORD_REQUIRED", "Password required");
send_msg_(hdl, failure);
return true;
}
@@ -634,8 +658,8 @@ bool SignalNegotiation::join_transmission(websocketpp::connection_hdl hdl,
transmission_id.c_str());
json message = {{"type", "user_join_transmission"},
{"transmission_id", transmission_id},
{"status", "failed"},
{"reason", "Remote unavailable"}};
{"status", "failed"}};
SetFailure(message, "REMOTE_UNAVAILABLE", "Remote unavailable");
send_msg_(hdl, message);
return;
}
+8 -4
View File
@@ -1011,6 +1011,7 @@ void SignalServer::SendMsg(websocketpp::connection_hdl hdl, json message) {
}
std::string login_id;
const auto failure_reason = message.value("reason", "");
const auto failure_code = message.value("error_code", "");
const bool reconnected = type == "login" && message.value("reconnected", false);
const bool reconnect_rejected = type == "login" && message.value("reconnect_rejected", false);
if (type == "login" && message.value("status", "") == "success") {
@@ -1019,7 +1020,7 @@ void SignalServer::SendMsg(websocketpp::connection_hdl hdl, json message) {
}
auto payload = message.dump();
server_.get_io_service().post(
[this, hdl, payload = std::move(payload), login_id, failure_reason,
[this, hdl, payload = std::move(payload), login_id, failure_reason, failure_code,
reconnected, reconnect_rejected] {
--pending_sends_;
auto it = connections_.find(hdl);
@@ -1034,11 +1035,14 @@ void SignalServer::SendMsg(websocketpp::connection_hdl hdl, json message) {
it->second->last_heartbeat = Clock::now();
}
if (reconnect_rejected) ++diagnostics_.reconnect_failed;
if (failure_reason == "Authentication failed")
if (failure_code == "AUTHENTICATION_FAILED" ||
(failure_code.empty() && failure_reason == "Authentication failed"))
++diagnostics_.authentication_failed;
else if (failure_reason == "Credential service busy")
else if (failure_code == "CREDENTIAL_SERVICE_BUSY" ||
(failure_code.empty() && failure_reason == "Credential service busy"))
++diagnostics_.credential_busy;
else if (failure_reason == "Too many authentication attempts")
else if (failure_code == "AUTHENTICATION_THROTTLED" ||
(failure_code.empty() && failure_reason == "Too many authentication attempts"))
++diagnostics_.authentication_throttled;
websocketpp::lib::error_code ec;
auto con = server_.get_con_from_hdl(hdl, ec);