Merge c/phone-account: device list + 2FA setup on the phone
This commit is contained in:
commit
151084f858
|
|
@ -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(); }
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue