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 加入這組金鑰", [],
+ ``);
+ 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()));