Phone: device list with per-device kick, 2FA setup sheet (C finish)

- 帳號 › 登入的裝置: every signed-in device with its last use; tap a device's
  id to sign just that one out; 全部登出 ends every session, this phone
  included — the stolen-session story ends on one page, on the phone
- 帳號 › 兩步驗證: enroll (secret + recovery codes shown once, code confirms),
  disable (proves the code); the login screen's second step landed with S2
- relay: POST /relay/sessions/drop {id} — a session is named by the first 8
  hash characters its row shows; kicking the phone you hold tells the app to
  sign itself out (was_self)
This commit is contained in:
AnyBot Dev 2026-11-09 18:08:51 +08:00
parent 4acfd39551
commit b4e4bf67e7
2 changed files with 97 additions and 1 deletions

View File

@ -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("") || `<div class="note" style="padding:12px 16px">沒有</div>`); 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("") || `<div class="note" style="padding:12px 16px">沒有</div>`);
const acts = []; 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: "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 && 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"'})); 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})); 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("")); 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; 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("") || `<div class="note" style="padding:12px 16px">只有這支手機</div>`;
sheet("登入的裝置 (" + ss.length + ")", [
{icon: "log-out", label: "全部登出", danger: true, on: async () => {
if (!await confirmSheet("全部登出?", "包含這支手機。其他裝置要重新用密碼(和驗證碼)登入。", "全部登出")) return;
await api("relay/sessions/logout", {json: {}}); signOut();
}},
], html + `<div class="note">踢掉某一台:按它的「裝置代號」。不知道是哪台就全部登出。</div>`);
// 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 加入這組金鑰", [],
`<div class="form"><div class="note">金鑰(在 App 選「輸入設定金鑰」貼上)</div><input class="field" readonly value="${esc(v.secret)}">
<div class="note">恢復碼——只顯示這一次,先抄下來</div><textarea class="field" readonly rows="3" style="font-family:ui-monospace,monospace">${v.recovery_codes.join(" ")}</textarea>
<div class="note">App 加好金鑰後,輸入它現在顯示的六位碼</div><input class="field" id="2fa-in" inputmode="numeric" placeholder="123456" autocomplete="one-time-code">
<button class="btn main" id="2fa-ok">開啟兩步驗證</button></div>`);
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) { function wireSettings(kind, st) {
const el = $("list"); const el = $("list");
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((x) => x[ev] = () => fn(x)); 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 === "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 === "push") notifySheet();
else if (k === "2fa") twoFaSheet();
else if (k === "devices") devicesSheet();
else if (k === "pair") pair(); 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 === "host") { S.host = ""; localStorage.removeItem("anybot.host"); S.version = 0; NAV.length = 0; pickHost(); }
else if (k === "out") { if (await confirmSheet("要登出這支手機嗎?", "", "登出")) signOut(); } else if (k === "out") { if (await confirmSheet("要登出這支手機嗎?", "", "登出")) signOut(); }

View File

@ -695,6 +695,7 @@ impl Relay {
("POST", ["relay", "2fa", "verify"]) => self.verify_2fa(&req), ("POST", ["relay", "2fa", "verify"]) => self.verify_2fa(&req),
("POST", ["relay", "2fa", "disable"]) => self.disable_2fa(&req), ("POST", ["relay", "2fa", "disable"]) => self.disable_2fa(&req),
("POST", ["relay", "sessions", "logout"]) => self.logout_everywhere(&req), ("POST", ["relay", "sessions", "logout"]) => self.logout_everywhere(&req),
("POST", ["relay", "sessions", "drop"]) => self.drop_session(&req),
("POST", ["relay", "logout"]) => { ("POST", ["relay", "logout"]) => {
let h = httpd::sha256_hex(&req.bearer()); let h = httpd::sha256_hex(&req.bearer());
let mut d = self.data.lock().unwrap(); let mut d = self.data.lock().unwrap();
@ -718,7 +719,7 @@ impl Relay {
v v
}) })
.collect(); .collect();
let sessions: Vec<Value> = d.sessions.iter().filter(|s| s.user == user).map(|s| json!({"device": s.device, "created": s.created, "last": s.last})).collect(); let sessions: Vec<Value> = 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.) // (`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); 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()})) 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})) 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. /// One-page logout: every session of this account is gone, including this one.
fn logout_everywhere(&self, req: &Req) -> Resp { fn logout_everywhere(&self, req: &Req) -> Resp {
let Some(user) = self.session(req) else { return Resp::error(401, "請先登入") }; 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 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] #[test]
fn two_factor_codes_recovery_once_and_logout_everywhere() { fn two_factor_codes_recovery_once_and_logout_everywhere() {
let dir = std::env::temp_dir().join(format!("anybot-relay-2fa-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("anybot-relay-2fa-{}", std::process::id()));