diff --git a/mobile/index.html b/mobile/index.html index eb20bfb..2ef0704 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -1601,6 +1601,8 @@ function drawSettings(kind, quiet) { if (st.devices) h += group("直接連線的手機", st.devices.map((d, i) => item({icon: "smartphone", k: d.name, v: d.last_seen ? "上次使用 " + ago(d.last_seen * 1000) : "登入於 " + ago(d.created * 1000), tap: 1, data: `data-dev="${i}"`})).join("") || `
沒有
`); const acts = []; if (S.relay) acts.push(item({icon: "bell", k: "通知", v: "Bot 需要你允許、或做完事時,手機跳通知", tap: 1, data: 'data-s="push"'})); + if (S.relay) acts.push(item({icon: "shield", k: "兩步驗證", v: "登入要密碼+驗證器 App 的六位碼", tap: 1, data: 'data-s="2fa"'})); + if (S.relay) acts.push(item({icon: "smartphone", k: "登入的裝置", v: "看看有哪些裝置登著,可逐台踢掉", tap: 1, data: 'data-s="devices"'})); if (S.relay && owner()) acts.push(item({icon: "plus", k: "加一台電腦", v: "Linux、伺服器當 Bot 的手腳", tap: 1, data: 'data-s="pair"'})); if (S.relay) acts.push(item({icon: "refresh", k: "切換到別台 AnyBot", tap: 1, data: 'data-s="host"'})); acts.push(item({icon: "log-out", k: "登出這支手機", tap: 1, data: 'data-s="out"', danger: true, chev: false})); @@ -1642,6 +1644,52 @@ function computersHtml(st) { if (others.length) h += group("帳號裡的電腦", others.map((c) => item({icon: {this: "monitor", device: "laptop", ssh: "server"}[c.kind] || "monitor", k: c.name, v: (c.online ? "在線上" : "離線") + (c.person ? " · 你正在用的" : ""), tap: c.kind === "this" && S.mac ? 1 : 0, data: c.kind === "this" && S.mac ? 'data-bx="mac"' : ""})).join("")); return h; } +async function devicesSheet() { + let me; + try { me = await api("relay/me"); } catch (e) { toast(e.message); return; } + const ss = me.sessions || []; + const html = ss.map((d) => item({icon: "smartphone", k: d.device || "裝置", v: "上次使用 " + ago(d.last * 1000), r: d.id})).join("") || `
只有這支手機
`; + sheet("登入的裝置 (" + ss.length + ")", [ + {icon: "log-out", label: "全部登出", danger: true, on: async () => { + if (!await confirmSheet("全部登出?", "包含這支手機。其他裝置要重新用密碼(和驗證碼)登入。", "全部登出")) return; + await api("relay/sessions/logout", {json: {}}); signOut(); + }}, + ], html + `
踢掉某一台:按它的「裝置代號」。不知道是哪台就全部登出。
`); + // Tapping a device's id (the .r span) kicks just it. + sheet.devices = ss; + $("sheet-in").querySelectorAll(".item .r").forEach((el, i) => { el.style.textDecoration = "underline"; el.onclick = async (e) => { + e.stopPropagation(); + const d = (sheet.devices || [])[i]; if (!d) return; + if (!await confirmSheet(`登出「${d.device || "裝置"}」?`, "那台手機要重新登入才能用。", "登出這台")) return; + const v = await api("relay/sessions/drop", {json: {id: d.id}}); + if (v.was_self) { signOut(); return; } + toast("已登出"); devicesSheet(); + }; }); +} +async function twoFaSheet() { + let me; + try { me = await api("relay/me"); } catch (e) { toast(e.message); return; } + if (!me.two_factor) { + if (!await confirmSheet("打開兩步驗證?", "之後登入要密碼,加驗證器 App(Google Authenticator、Authy、Apple 密碼…)的六位碼。會給你 8 個恢復碼——收好,遺失驗證器時用。", "開始設定", false)) return; + let v; + try { v = await api("relay/2fa/enroll", {json: {}}); } catch (e) { toast(e.message); return; } + const el = sheet("在驗證器 App 加入這組金鑰", [], + `
金鑰(在 App 選「輸入設定金鑰」貼上)
+
恢復碼——只顯示這一次,先抄下來
+
App 加好金鑰後,輸入它現在顯示的六位碼
+
`); + el.querySelector("#2fa-ok").onclick = async () => { + const b = el.querySelector("#2fa-ok"); b.disabled = true; + try { await api("relay/2fa/verify", {json: {code: el.querySelector("#2fa-in").value.trim()}}); closeSheet(); toast("兩步驗證已開啟,恢復碼收好"); } + catch (e) { toast(e.message); b.disabled = false; } + }; + return; + } + const code = await ask("關閉兩步驗證", "", "先輸入一個六位碼證明是你"); + if (!code) return; + try { await api("relay/2fa/disable", {json: {code}}); toast("兩步驗證已關閉"); } catch (e) { toast(e.message); } +} + function wireSettings(kind, st) { const el = $("list"); const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((x) => x[ev] = () => fn(x)); @@ -1685,6 +1733,8 @@ function wireSettings(kind, st) { } else if (k === "phonetheme") sheet("這支手機的外觀", THEMES.map(([v, n]) => ({label: n, check: phoneTheme() === v, on: () => { try { localStorage.setItem("anybot.theme", v); } catch (_) {} applyTheme(); drawSettings(kind, true); }}))); else if (k === "push") notifySheet(); + else if (k === "2fa") twoFaSheet(); + else if (k === "devices") devicesSheet(); else if (k === "pair") pair(); else if (k === "host") { S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; NAV.length = 0; pickHost(); } else if (k === "out") { if (await confirmSheet("要登出這支手機嗎?", "", "登出")) signOut(); } diff --git a/relay/src/main.rs b/relay/src/main.rs index 1cb2b64..050b12e 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -695,6 +695,7 @@ impl Relay { ("POST", ["relay", "2fa", "verify"]) => self.verify_2fa(&req), ("POST", ["relay", "2fa", "disable"]) => self.disable_2fa(&req), ("POST", ["relay", "sessions", "logout"]) => self.logout_everywhere(&req), + ("POST", ["relay", "sessions", "drop"]) => self.drop_session(&req), ("POST", ["relay", "logout"]) => { let h = httpd::sha256_hex(&req.bearer()); let mut d = self.data.lock().unwrap(); @@ -718,7 +719,7 @@ impl Relay { v }) .collect(); - let sessions: Vec = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"device": s.device, "created": s.created, "last": s.last})).collect(); + let sessions: Vec = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"id": &s.hash[..8], "device": s.device, "created": s.created, "last": s.last})).collect(); // (`d` above is this handler's lock: read the users through it, never a second lock.) let totp_on = d.users.iter().find(|u| u.name == user).map(|u| !u.totp.is_empty()).unwrap_or(false); Resp::json(&json!({"user": user, "two_factor": totp_on, "hosts": hosts, "sessions": sessions, "backup": self.dir.join("backups").join(format!("{}.bin", httpd::sha256_hex(&user))).exists()})) @@ -1152,6 +1153,23 @@ impl Relay { Resp::json(&json!({"ok": true})) } + /// Kick one device by its short id (the first 8 hash characters the device list shows). + fn drop_session(&self, req: &Req) -> Resp { + let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; + let id = req.json()["id"].as_str().unwrap_or("").to_string(); + if id.len() < 6 { + return Resp::error(400, "要裝置清單裡的裝置代號(6 個字以上)"); + } + let mut d = self.data.lock().unwrap(); + let me = httpd::sha256_hex(&req.bearer()); + let before = d.sessions.len(); + d.sessions.retain(|s| !(s.user == user && s.hash.starts_with(&id) && !s.hash.is_empty())); + let left = d.sessions.iter().filter(|s| s.user == user).count(); + self.save(&d); + // Signing out the phone you hold is a normal move: say so, and the app logs out. + Resp::json(&json!({"ok": before != d.sessions.len(), "left": left, "was_self": d.sessions.iter().all(|s| s.hash != me)})) + } + /// One-page logout: every session of this account is gone, including this one. fn logout_everywhere(&self, req: &Req) -> Resp { let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; @@ -1276,6 +1294,34 @@ mod tests { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() / 30 } + #[test] + fn one_device_can_be_kicked_from_the_list() { + let dir = std::env::temp_dir().join(format!("anybot-relay-kick-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let relay = Arc::new(Relay::load(dir.clone())); + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let r = relay.clone(); + std::thread::spawn(move || httpd::serve(l, Arc::new(move |req| r.handle(req)), Arc::new(|| true))); + + post(port, "/relay/signup", "", json!({"user": "kick", "password": "12345678"})); + let phone = post(port, "/relay/login", "", json!({"user": "kick", "password": "12345678", "device": "iPhone"})).1["token"].as_str().unwrap().to_string(); + let tablet = post(port, "/relay/login", "", json!({"user": "kick", "password": "12345678", "device": "iPad"})).1["token"].as_str().unwrap().to_string(); + let (_, v) = get(port, "/relay/me", &phone); + let me: Value = serde_json::from_slice(&v).unwrap(); + let dev = me["sessions"].as_array().unwrap().iter().find(|s| s["device"] == "iPad").unwrap().clone(); + post(port, "/relay/sessions/drop", &phone, json!({"id": dev["id"]})); + assert_eq!(get(port, "/relay/me", &tablet).0, 401, "the kicked device is out"); + assert_eq!(get(port, "/relay/me", &phone).0, 200, "the kicker stays in"); + // Kicking the phone you hold: the reply says so, and nothing is left. + let (_, v) = get(port, "/relay/me", &phone); + let me: Value = serde_json::from_slice(&v).unwrap(); + let self_id = me["sessions"].as_array().unwrap()[0]["id"].clone(); + let (_, v) = post(port, "/relay/sessions/drop", &phone, json!({"id": self_id})); + assert_eq!(v["was_self"], true); + assert_eq!(get(port, "/relay/me", &phone).0, 401); + } + #[test] fn two_factor_codes_recovery_once_and_logout_everywhere() { let dir = std::env::temp_dir().join(format!("anybot-relay-2fa-{}", std::process::id()));