mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
sessions: every signed-in browser is a row, and one can be signed out
A session cookie now carries the id of a row in the new sessions table, and is refused once that row is gone. :sessions lists the browsers signed in, newest first, and :sessions rm <n> signs one out without touching the rest; it locks the vault too, since the vault is open for the whole workspace. Logging out deletes the row, :logout! and a login password change delete them all, and the sweep drops expired rows. last_seen is kept in memory and written at most every five minutes. Cookies issued before this carry no id and are refused: every browser logs in once more after the upgrade.
This commit is contained in:
1 parent
6c82264f57
commit
165561f6a2
10 files changed
+540
-56
No files matched your search
@@ -42,7 +42,9 @@ keeps running untouched.
|
|||||||
`WORKSPACE_SECRET` signs the session cookie; generate one with
|
`WORKSPACE_SECRET` signs the session cookie; generate one with
|
||||||
`openssl rand -base64 32`. Changing it logs every device out, and so does
|
`openssl rand -base64 32`. Changing it logs every device out, and so does
|
||||||
changing `WORKSPACE_PASSWORD` or `:logout!`, which ends every session but
|
changing `WORKSPACE_PASSWORD` or `:logout!`, which ends every session but
|
||||||
needs no restart. A setting that is set but cannot be read — a number out of
|
needs no restart. Every signed-in browser is a row on the server: `:sessions`
|
||||||
|
lists them and `:sessions rm <n>` signs one out — a lost phone, say — without
|
||||||
|
touching the rest, and locks the vault as well. A setting that is set but cannot be read — a number out of
|
||||||
range, a switch that is neither on nor off — stops the start with the reason,
|
range, a switch that is neither on nor off — stops the start with the reason,
|
||||||
rather than being taken for its default. The database and
|
rather than being taken for its default. The database and
|
||||||
uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is
|
uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is
|
||||||
@@ -106,7 +108,7 @@ list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
|
|||||||
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
|
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
|
||||||
and lines, `:search!` searches every space, `:set clipboard` / `:set
|
and lines, `:search!` searches every space, `:set clipboard` / `:set
|
||||||
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the
|
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the
|
||||||
session and `:logout!` every session. The tree has its own: `:space [name]` switches (`:space! <name>`
|
session, `:logout!` every session and `:sessions rm <n>` one other. The tree has its own: `:space [name]` switches (`:space! <name>`
|
||||||
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
|
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
|
||||||
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
|
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
|
||||||
selected, and `:import` uploads a folder into the one being browsed
|
selected, and `:import` uploads a folder into the one being browsed
|
||||||
@@ -561,6 +563,8 @@ sealed or not.
|
|||||||
| `GET` | `/api/health` | public: up, and the database answers — for a supervisor |
|
| `GET` | `/api/health` | public: up, and the database answers — for a supervisor |
|
||||||
| `POST` | `/api/login`, `/api/logout` | session; `/api/logout?everywhere=true` ends every one |
|
| `POST` | `/api/login`, `/api/logout` | session; `/api/logout?everywhere=true` ends every one |
|
||||||
| `GET` | `/api/me` | current user |
|
| `GET` | `/api/me` | current user |
|
||||||
|
| `GET` | `/api/sessions` | every signed-in browser, the one seen last first |
|
||||||
|
| `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault |
|
||||||
| `GET`/`POST` | `/api/spaces` | the spaces / create one |
|
| `GET`/`POST` | `/api/spaces` | the spaces / create one |
|
||||||
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create |
|
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create |
|
||||||
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) |
|
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) |
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Every signed-in browser gets a row, named by an id its cookie carries, so
|
||||||
|
-- one can be listed and signed out without signing out the rest. A cookie
|
||||||
|
-- whose row is gone is refused even though its signature still holds.
|
||||||
|
-- The epoch in `auth` stays as a second fence: raising it ends every row too.
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
+247
-48
@@ -1,4 +1,4 @@
|
|||||||
use std::convert::Infallible;
|
use std::{collections::HashMap, convert::Infallible, sync::Mutex};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, OptionalFromRequestParts, Request},
|
extract::{FromRequestParts, OptionalFromRequestParts, Request},
|
||||||
@@ -27,36 +27,153 @@ fn sign(secret: &[u8], payload: &str) -> String {
|
|||||||
B64.encode(mac.finalize().into_bytes())
|
B64.encode(mac.finalize().into_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
|
/// A session token is `base64("{expires}:{epoch}:{id}").base64(hmac)`.
|
||||||
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session.
|
/// The signature keeps anyone from making one up; the id names the
|
||||||
/// A cookie is good until it expires and for as long as the epoch it was
|
/// [`Sessions`] row it belongs to, and a cookie is good only while that row
|
||||||
/// issued under is the current one.
|
/// is there, it has not expired, and the epoch it was issued under is the
|
||||||
pub fn issue_token(config: &Config, epoch: i64) -> String {
|
/// current one. Rotating `WORKSPACE_SECRET` still invalidates every session.
|
||||||
let expires = crate::now() + config.session_days * 86_400;
|
pub fn issue_token(expires: i64, epoch: i64, id: &str, config: &Config) -> String {
|
||||||
let payload = B64.encode(format!("{expires}:{epoch}"));
|
let payload = B64.encode(format!("{expires}:{epoch}:{id}"));
|
||||||
let signature = sign(&config.secret, &payload);
|
let signature = sign(&config.secret, &payload);
|
||||||
format!("{payload}.{signature}")
|
format!("{payload}.{signature}")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_token(config: &Config, token: &str, epoch: i64) -> bool {
|
/// When a session started now runs out.
|
||||||
let Some((payload, signature)) = token.split_once('.') else {
|
pub fn session_expiry(config: &Config) -> i64 {
|
||||||
return false;
|
crate::now() + config.session_days * 86_400
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/// The session id a token carries, if it is genuine, unexpired and of this
|
||||||
|
/// epoch. A cookie from before sessions had ids carries none, and is refused.
|
||||||
|
pub fn verify_token(config: &Config, token: &str, epoch: i64) -> Option<String> {
|
||||||
|
let (payload, signature) = token.split_once('.')?;
|
||||||
let expected = sign(&config.secret, payload);
|
let expected = sign(&config.secret, payload);
|
||||||
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) {
|
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) {
|
||||||
return false;
|
return None;
|
||||||
|
}
|
||||||
|
let decoded = String::from_utf8(B64.decode(payload).ok()?).ok()?;
|
||||||
|
let mut fields = decoded.splitn(3, ':');
|
||||||
|
let expires = fields.next()?.parse::<i64>().ok()?;
|
||||||
|
let issued_in = fields.next()?.parse::<i64>().ok()?;
|
||||||
|
let id = fields.next().filter(|id| !id.is_empty())?;
|
||||||
|
(expires > crate::now() && issued_in == epoch).then(|| id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often a session that is in use gets its `last_seen` written down.
|
||||||
|
/// In between, it is kept in memory only.
|
||||||
|
const SEEN_EVERY: i64 = 300;
|
||||||
|
|
||||||
|
struct Live {
|
||||||
|
expires_at: i64,
|
||||||
|
last_seen: i64,
|
||||||
|
written: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed-in sessions, mirrored in memory from the `sessions` table so
|
||||||
|
/// a request costs no query to check.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Sessions {
|
||||||
|
live: Mutex<HashMap<String, Live>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sessions {
|
||||||
|
pub async fn load(db: &sqlx::SqlitePool) -> anyhow::Result<Self> {
|
||||||
|
let rows: Vec<(String, i64, i64)> =
|
||||||
|
sqlx::query_as("SELECT id, expires_at, last_seen FROM sessions WHERE expires_at > ?1")
|
||||||
|
.bind(crate::now())
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
let live = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, expires_at, last_seen)| {
|
||||||
|
let live = Live {
|
||||||
|
expires_at,
|
||||||
|
last_seen,
|
||||||
|
written: last_seen,
|
||||||
|
};
|
||||||
|
(id, live)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Self {
|
||||||
|
live: Mutex::new(live),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new session, for a browser that just logged in.
|
||||||
|
pub async fn open(
|
||||||
|
&self,
|
||||||
|
db: &sqlx::SqlitePool,
|
||||||
|
user_agent: &str,
|
||||||
|
expires_at: i64,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let id = B64.encode(rand::random::<[u8; 16]>());
|
||||||
|
let now = crate::now();
|
||||||
|
let user_agent: String = user_agent.chars().take(200).collect();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (id, created_at, last_seen, expires_at, user_agent)
|
||||||
|
VALUES (?1, ?2, ?2, ?3, ?4)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(now)
|
||||||
|
.bind(expires_at)
|
||||||
|
.bind(&user_agent)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
let mut live = self.live.lock().expect("sessions lock");
|
||||||
|
live.retain(|_, session| session.expires_at > now);
|
||||||
|
live.insert(
|
||||||
|
id.clone(),
|
||||||
|
Live {
|
||||||
|
expires_at,
|
||||||
|
last_seen: now,
|
||||||
|
written: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `id` is signed in, noting that it was just seen. `Some(true)`
|
||||||
|
/// asks the caller to write that down.
|
||||||
|
fn seen(&self, id: &str) -> Option<bool> {
|
||||||
|
let now = crate::now();
|
||||||
|
let mut live = self.live.lock().expect("sessions lock");
|
||||||
|
let session = live.get_mut(id).filter(|s| s.expires_at > now)?;
|
||||||
|
session.last_seen = now;
|
||||||
|
let due = now - session.written >= SEEN_EVERY;
|
||||||
|
if due {
|
||||||
|
session.written = now;
|
||||||
|
}
|
||||||
|
Some(due)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `id` was last seen, fresher than the table may have it.
|
||||||
|
pub fn last_seen(&self, id: &str) -> Option<i64> {
|
||||||
|
let live = self.live.lock().expect("sessions lock");
|
||||||
|
live.get(id).map(|session| session.last_seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign one session out. Says whether there was one to sign out.
|
||||||
|
pub async fn end(&self, db: &sqlx::SqlitePool, id: &str) -> anyhow::Result<bool> {
|
||||||
|
let removed = sqlx::query("DELETE FROM sessions WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
let held = self
|
||||||
|
.live
|
||||||
|
.lock()
|
||||||
|
.expect("sessions lock")
|
||||||
|
.remove(id)
|
||||||
|
.is_some();
|
||||||
|
Ok(removed > 0 || held)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign every session out.
|
||||||
|
pub async fn end_all(&self, db: &sqlx::SqlitePool) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions").execute(db).await?;
|
||||||
|
self.live.lock().expect("sessions lock").clear();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
let Ok(decoded) = B64.decode(payload) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let decoded = String::from_utf8_lossy(&decoded);
|
|
||||||
// A cookie from before epochs existed carries only the expiry, and
|
|
||||||
// belongs to the first epoch.
|
|
||||||
let (expires, issued_in) = decoded.split_once(':').unwrap_or((&decoded, "0"));
|
|
||||||
let (Ok(expires), Ok(issued_in)) = (expires.parse::<i64>(), issued_in.parse::<i64>()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
expires > crate::now() && issued_in == epoch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The session epoch: what every cookie must have been signed under.
|
/// The session epoch: what every cookie must have been signed under.
|
||||||
@@ -82,6 +199,7 @@ impl Epoch {
|
|||||||
Some((epoch, previous)) if previous == hash => return Ok(epoch),
|
Some((epoch, previous)) if previous == hash => return Ok(epoch),
|
||||||
Some((epoch, _)) => {
|
Some((epoch, _)) => {
|
||||||
tracing::info!("the login password changed: every device is signed out");
|
tracing::info!("the login password changed: every device is signed out");
|
||||||
|
sqlx::query("DELETE FROM sessions").execute(db).await?;
|
||||||
epoch + 1
|
epoch + 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -96,8 +214,10 @@ impl Epoch {
|
|||||||
Ok(epoch)
|
Ok(epoch)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign every device out: the epoch moves on, and no cookie has it yet.
|
/// Sign every device out: the epoch moves on, no cookie has it yet, and
|
||||||
|
/// no session row is left either.
|
||||||
pub async fn advance(state: &AppState) -> anyhow::Result<i64> {
|
pub async fn advance(state: &AppState) -> anyhow::Result<i64> {
|
||||||
|
state.sessions.end_all(&state.db).await?;
|
||||||
let epoch: i64 =
|
let epoch: i64 =
|
||||||
sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
|
sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
@@ -160,7 +280,10 @@ fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extractor that rejects the request unless a valid session cookie is present.
|
/// Extractor that rejects the request unless a valid session cookie is present.
|
||||||
pub struct Session;
|
pub struct Session {
|
||||||
|
/// The `sessions` row the cookie belongs to.
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for Session {
|
impl FromRequestParts<AppState> for Session {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
@@ -172,11 +295,22 @@ impl FromRequestParts<AppState> for Session {
|
|||||||
let token =
|
let token =
|
||||||
cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?;
|
cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?;
|
||||||
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
|
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
if verify_token(&state.config, token, epoch) {
|
let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?;
|
||||||
Ok(Session)
|
let due = state.sessions.seen(&id).ok_or(AppError::Unauthorized)?;
|
||||||
} else {
|
if due {
|
||||||
Err(AppError::Unauthorized)
|
let (db, id) = (state.db.clone(), id.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let written = sqlx::query("UPDATE sessions SET last_seen = ?1 WHERE id = ?2")
|
||||||
|
.bind(crate::now())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&db)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = written {
|
||||||
|
tracing::warn!("could not note a session as seen: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
Ok(Session { id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +376,8 @@ mod tests {
|
|||||||
use axum::http::{HeaderMap, HeaderValue};
|
use axum::http::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
clear_cookie, credentials_match, is_same_origin, issue_token, session_cookie, verify_token,
|
clear_cookie, credentials_match, is_same_origin, issue_token, session_cookie,
|
||||||
|
session_expiry, verify_token, Sessions,
|
||||||
};
|
};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
||||||
@@ -250,24 +385,31 @@ mod tests {
|
|||||||
Config::for_tests(session_days)
|
Config::for_tests(session_days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn token(config: &Config, epoch: i64) -> String {
|
||||||
|
issue_token(session_expiry(config), epoch, "sid", config)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_fresh_token_verifies() {
|
fn a_fresh_token_verifies() {
|
||||||
let config = config(30);
|
let config = config(30);
|
||||||
assert!(verify_token(&config, &issue_token(&config, 0), 0));
|
assert_eq!(
|
||||||
|
verify_token(&config, &token(&config, 0), 0).as_deref(),
|
||||||
|
Some("sid")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_expired_token_does_not() {
|
fn an_expired_token_does_not() {
|
||||||
let config = config(-1);
|
let config = config(-1);
|
||||||
assert!(!verify_token(&config, &issue_token(&config, 0), 0));
|
assert!(verify_token(&config, &token(&config, 0), 0).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rotating_the_secret_invalidates_every_session() {
|
fn rotating_the_secret_invalidates_every_session() {
|
||||||
let token = issue_token(&config(30), 0);
|
let token = token(&config(30), 0);
|
||||||
let mut rotated = config(30);
|
let mut rotated = config(30);
|
||||||
rotated.secret = b"a-different-secret-entirely".to_vec();
|
rotated.secret = b"a-different-secret-entirely".to_vec();
|
||||||
assert!(!verify_token(&rotated, &token, 0));
|
assert!(verify_token(&rotated, &token, 0).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -287,25 +429,25 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_tampered_token_does_not_verify() {
|
fn a_tampered_token_does_not_verify() {
|
||||||
let config = config(30);
|
let config = config(30);
|
||||||
let token = issue_token(&config, 0);
|
let token = token(&config, 0);
|
||||||
let (payload, signature) = token.split_once('.').unwrap();
|
let (payload, signature) = token.split_once('.').unwrap();
|
||||||
|
|
||||||
// A far-future expiry the holder signed themselves.
|
// A far-future expiry the holder signed themselves.
|
||||||
let forged = base64::Engine::encode(&super::B64, "99999999999");
|
let forged = base64::Engine::encode(&super::B64, "99999999999:0:sid");
|
||||||
assert!(!verify_token(&config, &format!("{forged}.{signature}"), 0));
|
assert!(verify_token(&config, &format!("{forged}.{signature}"), 0).is_none());
|
||||||
|
|
||||||
// The real expiry with the last byte of the signature flipped.
|
// The real expiry with the last byte of the signature flipped.
|
||||||
let mut bytes = signature.as_bytes().to_vec();
|
let mut bytes = signature.as_bytes().to_vec();
|
||||||
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
|
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
|
||||||
let flipped = String::from_utf8(bytes).unwrap();
|
let flipped = String::from_utf8(bytes).unwrap();
|
||||||
assert!(!verify_token(&config, &format!("{payload}.{flipped}"), 0));
|
assert!(verify_token(&config, &format!("{payload}.{flipped}"), 0).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_tokens_are_rejected_rather_than_panicking() {
|
fn malformed_tokens_are_rejected_rather_than_panicking() {
|
||||||
let config = config(30);
|
let config = config(30);
|
||||||
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] {
|
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] {
|
||||||
assert!(!verify_token(&config, token, 0), "{token:?}");
|
assert!(verify_token(&config, token, 0).is_none(), "{token:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,15 +502,72 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_cookie_from_an_earlier_epoch_is_refused() {
|
fn a_cookie_from_an_earlier_epoch_is_refused() {
|
||||||
let config = config(30);
|
let config = config(30);
|
||||||
let token = issue_token(&config, 3);
|
let token = token(&config, 3);
|
||||||
assert!(verify_token(&config, &token, 3));
|
assert!(verify_token(&config, &token, 3).is_some());
|
||||||
assert!(!verify_token(&config, &token, 4));
|
assert!(verify_token(&config, &token, 4).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
// Issued before epochs: only the expiry, and good in the first one.
|
#[test]
|
||||||
|
fn a_cookie_without_a_session_id_is_refused() {
|
||||||
|
let config = config(30);
|
||||||
let expires = crate::now() + 3600;
|
let expires = crate::now() + 3600;
|
||||||
let payload = base64::Engine::encode(&super::B64, expires.to_string());
|
// From before epochs, and from before session rows.
|
||||||
let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload));
|
for payload in [
|
||||||
assert!(verify_token(&config, &legacy, 0));
|
expires.to_string(),
|
||||||
assert!(!verify_token(&config, &legacy, 1));
|
format!("{expires}:0"),
|
||||||
|
format!("{expires}:0:"),
|
||||||
|
] {
|
||||||
|
let payload = base64::Engine::encode(&super::B64, payload);
|
||||||
|
let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload));
|
||||||
|
assert!(verify_token(&config, &legacy, 0).is_none(), "{payload}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn db() -> sqlx::SqlitePool {
|
||||||
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_session_ends_and_the_others_stay() {
|
||||||
|
let db = db().await;
|
||||||
|
let sessions = Sessions::default();
|
||||||
|
let expires = crate::now() + 3600;
|
||||||
|
let phone = sessions.open(&db, "phone", expires).await.unwrap();
|
||||||
|
let laptop = sessions.open(&db, "laptop", expires).await.unwrap();
|
||||||
|
assert_ne!(phone, laptop);
|
||||||
|
assert_eq!(sessions.seen(&phone), Some(false));
|
||||||
|
|
||||||
|
assert!(sessions.end(&db, &phone).await.unwrap());
|
||||||
|
assert_eq!(sessions.seen(&phone), None);
|
||||||
|
assert!(sessions.seen(&laptop).is_some());
|
||||||
|
assert!(!sessions.end(&db, &phone).await.unwrap());
|
||||||
|
|
||||||
|
// What is kept in memory is what a restart reads back.
|
||||||
|
let reloaded = Sessions::load(&db).await.unwrap();
|
||||||
|
assert_eq!(reloaded.seen(&phone), None);
|
||||||
|
assert!(reloaded.seen(&laptop).is_some());
|
||||||
|
|
||||||
|
sessions.end_all(&db).await.unwrap();
|
||||||
|
assert_eq!(sessions.seen(&laptop), None);
|
||||||
|
let left: i64 = sqlx::query_scalar("SELECT count(*) FROM sessions")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_expired_session_is_not_signed_in() {
|
||||||
|
let db = db().await;
|
||||||
|
let sessions = Sessions::default();
|
||||||
|
let gone = sessions.open(&db, "old", crate::now() - 1).await.unwrap();
|
||||||
|
assert_eq!(sessions.seen(&gone), None);
|
||||||
|
assert!(Sessions::load(&db).await.unwrap().seen(&gone).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,7 @@ async fn main() -> Result<()> {
|
|||||||
sweep::spawn(db.clone(), config.uploads_dir());
|
sweep::spawn(db.clone(), config.uploads_dir());
|
||||||
|
|
||||||
let epoch = auth::Epoch::load(&db, &config).await?;
|
let epoch = auth::Epoch::load(&db, &config).await?;
|
||||||
|
let sessions = Arc::new(auth::Sessions::load(&db).await?);
|
||||||
let vault = vault::Vault::new(db.clone(), config.vault_idle);
|
let vault = vault::Vault::new(db.clone(), config.vault_idle);
|
||||||
vault.spawn_idle_lock();
|
vault.spawn_idle_lock();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -134,6 +135,7 @@ async fn main() -> Result<()> {
|
|||||||
backup,
|
backup,
|
||||||
notify,
|
notify,
|
||||||
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
|
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
|
||||||
|
sessions,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(sftp_bind) = config.sftp.bind {
|
if let Some(sftp_bind) = config.sftp.bind {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)),
|
post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)),
|
||||||
)
|
)
|
||||||
.route("/logout", post(session::logout))
|
.route("/logout", post(session::logout))
|
||||||
|
.route("/sessions", get(session::list))
|
||||||
|
.route("/sessions/{id}", delete(session::revoke))
|
||||||
.route("/export", get(export::export))
|
.route("/export", get(export::export))
|
||||||
.route("/backup", get(backup::status).post(backup::run))
|
.route("/backup", get(backup::status).post(backup::run))
|
||||||
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
||||||
@@ -140,6 +142,7 @@ pub(crate) mod tests {
|
|||||||
login_throttle: Arc::default(),
|
login_throttle: Arc::default(),
|
||||||
vault_throttle: Arc::default(),
|
vault_throttle: Arc::default(),
|
||||||
epoch: Arc::new(AtomicI64::new(0)),
|
epoch: Arc::new(AtomicI64::new(0)),
|
||||||
|
sessions: Arc::default(),
|
||||||
config,
|
config,
|
||||||
db,
|
db,
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-4
@@ -1,10 +1,10 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -46,9 +46,13 @@ pub async fn login(
|
|||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
state.notify.note_login(user_agent).await;
|
state.notify.note_login(user_agent).await;
|
||||||
|
|
||||||
|
let expires = auth::session_expiry(&state.config);
|
||||||
|
let id = state.sessions.open(&state.db, user_agent, expires).await?;
|
||||||
let token = auth::issue_token(
|
let token = auth::issue_token(
|
||||||
&state.config,
|
expires,
|
||||||
state.epoch.load(std::sync::atomic::Ordering::SeqCst),
|
state.epoch.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
&id,
|
||||||
|
&state.config,
|
||||||
);
|
);
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -75,8 +79,9 @@ pub async fn logout(
|
|||||||
// Leaving is the moment the vault should not outlive — but only the owner
|
// Leaving is the moment the vault should not outlive — but only the owner
|
||||||
// leaving: anyone may clear a cookie they do not have, and that must not
|
// leaving: anyone may clear a cookie they do not have, and that must not
|
||||||
// be a way to shut the vault on the one who does.
|
// be a way to shut the vault on the one who does.
|
||||||
if session.is_some() {
|
if let Some(session) = session {
|
||||||
state.vault.lock();
|
state.vault.lock();
|
||||||
|
state.sessions.end(&state.db, &session.id).await?;
|
||||||
if query.everywhere {
|
if query.everywhere {
|
||||||
auth::Epoch::advance(&state).await?;
|
auth::Epoch::advance(&state).await?;
|
||||||
tracing::info!("signed out everywhere");
|
tracing::info!("signed out everywhere");
|
||||||
@@ -93,6 +98,59 @@ pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse
|
|||||||
Json(json!({ "user": state.config.username }))
|
Json(json!({ "user": state.config.username }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct SessionSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub last_seen: i64,
|
||||||
|
pub user_agent: String,
|
||||||
|
/// The session asking.
|
||||||
|
#[sqlx(skip)]
|
||||||
|
pub current: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/sessions` — every browser signed in, the one seen last first.
|
||||||
|
pub async fn list(
|
||||||
|
session: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<SessionSummary>>> {
|
||||||
|
let mut sessions: Vec<SessionSummary> = sqlx::query_as(
|
||||||
|
"SELECT id, created_at, last_seen, user_agent FROM sessions WHERE expires_at > ?1",
|
||||||
|
)
|
||||||
|
.bind(crate::now())
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
for summary in &mut sessions {
|
||||||
|
summary.current = summary.id == session.id;
|
||||||
|
if let Some(seen) = state.sessions.last_seen(&summary.id) {
|
||||||
|
summary.last_seen = summary.last_seen.max(seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions.sort_by_key(|summary| std::cmp::Reverse(summary.last_seen));
|
||||||
|
Ok(Json(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/sessions/{id}` — sign one other browser out. The vault is
|
||||||
|
/// locked as well: it is open for the whole workspace, and the browser just
|
||||||
|
/// signed out may be the one that opened it.
|
||||||
|
pub async fn revoke(
|
||||||
|
session: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
if id == session.id {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"that is the session you are using — log out to end it".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !state.sessions.end(&state.db, &id).await? {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
state.vault.lock();
|
||||||
|
tracing::info!("a session was signed out; the vault is locked");
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/health` — for whatever supervises the process. Answers without a
|
/// `GET /api/health` — for whatever supervises the process. Answers without a
|
||||||
/// session and says nothing about the workspace, only that the server is up
|
/// session and says nothing about the workspace, only that the server is up
|
||||||
/// and its database answers.
|
/// and its database answers.
|
||||||
@@ -108,3 +166,136 @@ pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{header, Request, StatusCode},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn app() -> Router {
|
||||||
|
let dir = std::env::temp_dir().join(format!("sessions-{}", uuid::Uuid::new_v4()));
|
||||||
|
let state = crate::routes::tests::state_in(dir).await;
|
||||||
|
crate::auth::Epoch::load(&state.db, &state.config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
crate::routes::api_router(1024).with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(
|
||||||
|
app: &Router,
|
||||||
|
method: &str,
|
||||||
|
uri: &str,
|
||||||
|
cookie: Option<&str>,
|
||||||
|
) -> (StatusCode, String) {
|
||||||
|
let mut request = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/json");
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
request = request.header(header::COOKIE, cookie);
|
||||||
|
}
|
||||||
|
let body = if uri == "/login" {
|
||||||
|
r#"{"username":"narl","password":"hunter2"}"#
|
||||||
|
} else {
|
||||||
|
"{}"
|
||||||
|
};
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(request.body(Body::from(body)).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let cookie = response
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split(';').next())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if uri == "/login" {
|
||||||
|
(status, cookie)
|
||||||
|
} else {
|
||||||
|
(status, String::from_utf8_lossy(&body).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_signed_out_session_is_refused_while_the_others_carry_on() {
|
||||||
|
let app = app().await;
|
||||||
|
let (_, phone) = call(&app, "POST", "/login", None).await;
|
||||||
|
let (_, laptop) = call(&app, "POST", "/login", None).await;
|
||||||
|
assert_ne!(phone, laptop);
|
||||||
|
|
||||||
|
let (status, listed) = call(&app, "GET", "/sessions", Some(&laptop)).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let listed: Vec<serde_json::Value> = serde_json::from_str(&listed).unwrap();
|
||||||
|
assert_eq!(listed.len(), 2);
|
||||||
|
let mine = listed.iter().find(|s| s["current"] == true).unwrap();
|
||||||
|
let other = listed.iter().find(|s| s["current"] == false).unwrap();
|
||||||
|
|
||||||
|
// Not this way for the session asking.
|
||||||
|
let own = format!("/sessions/{}", mine["id"].as_str().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "DELETE", &own, Some(&laptop)).await.0,
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
|
||||||
|
let other = format!("/sessions/{}", other["id"].as_str().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "DELETE", &other, Some(&laptop)).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/me", Some(&phone)).await.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/me", Some(&laptop)).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "DELETE", &other, Some(&laptop)).await.0,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logging_out_everywhere_ends_every_session() {
|
||||||
|
let app = app().await;
|
||||||
|
let (_, phone) = call(&app, "POST", "/login", None).await;
|
||||||
|
let (_, laptop) = call(&app, "POST", "/login", None).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "POST", "/logout", Some(&phone)).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/me", Some(&phone)).await.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/me", Some(&laptop)).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, tablet) = call(&app, "POST", "/login", None).await;
|
||||||
|
let everywhere = "/logout?everywhere=true";
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "POST", everywhere, Some(&tablet)).await.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
for cookie in [&laptop, &tablet] {
|
||||||
|
assert_eq!(
|
||||||
|
call(&app, "GET", "/me", Some(cookie)).await.0,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -3,7 +3,8 @@ use std::sync::{atomic::AtomicI64, Arc};
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault,
|
auth::Sessions, backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle,
|
||||||
|
vault::Vault,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -23,4 +24,6 @@ pub struct AppState {
|
|||||||
/// Signed into every session cookie, which must carry the current value
|
/// Signed into every session cookie, which must carry the current value
|
||||||
/// to be accepted; see [`crate::auth::Epoch`].
|
/// to be accepted; see [`crate::auth::Epoch`].
|
||||||
pub epoch: Arc<AtomicI64>,
|
pub epoch: Arc<AtomicI64>,
|
||||||
|
/// Every signed-in browser; a cookie is good only while its row is here.
|
||||||
|
pub sessions: Arc<Sessions>,
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,11 @@ impl Swept {
|
|||||||
pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> {
|
pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> {
|
||||||
let mut swept = Swept::default();
|
let mut swept = Swept::default();
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE expires_at < ?1")
|
||||||
|
.bind(crate::now())
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Rows first, so the files they leave are strays by the time the
|
// Rows first, so the files they leave are strays by the time the
|
||||||
// directory is walked, and go in the same pass.
|
// directory is walked, and go in the same pass.
|
||||||
let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400;
|
let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400;
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface SpaceOverview {
|
|||||||
recent: NodeSummary[]
|
recent: NodeSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A browser that is signed in. */
|
||||||
|
export interface SignedIn {
|
||||||
|
id: string
|
||||||
|
created_at: number
|
||||||
|
last_seen: number
|
||||||
|
user_agent: string
|
||||||
|
/** The session asking. */
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** A public key allowed to mount the workspace over SFTP. */
|
/** A public key allowed to mount the workspace over SFTP. */
|
||||||
export interface SshKey {
|
export interface SshKey {
|
||||||
id: number
|
id: number
|
||||||
@@ -460,6 +470,9 @@ export const api = {
|
|||||||
readShared: (token: string, key: string) =>
|
readShared: (token: string, key: string) =>
|
||||||
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
||||||
|
|
||||||
|
sessions: () => request<SignedIn[]>('/sessions'),
|
||||||
|
revokeSession: (id: string) =>
|
||||||
|
request<{ ok: true }>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
|
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
|
||||||
|
|
||||||
addSshKey: (name: string, public_key: string) =>
|
addSshKey: (name: string, public_key: string) =>
|
||||||
|
|||||||
+54
-1
@@ -1,6 +1,6 @@
|
|||||||
import { api, isContainer, type BackupStatus } from './api'
|
import { api, isContainer, type BackupStatus } from './api'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { formatDay, parseDay, parseDue, sealedNote, type ScrollAmount } from './util'
|
import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util'
|
||||||
|
|
||||||
export type { ScrollAmount }
|
export type { ScrollAmount }
|
||||||
|
|
||||||
@@ -102,6 +102,33 @@ const ago = (at: number | null) => {
|
|||||||
return `${Math.round(seconds / 86400)}d ago`
|
return `${Math.round(seconds / 86400)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "firefox on linux", out of a user-agent string, as far as it says. */
|
||||||
|
function browserOf(agent: string): string {
|
||||||
|
const browser = /Edg\//.test(agent)
|
||||||
|
? 'edge'
|
||||||
|
: /Firefox\//.test(agent)
|
||||||
|
? 'firefox'
|
||||||
|
: /Chrome\//.test(agent)
|
||||||
|
? 'chrome'
|
||||||
|
: /Safari\//.test(agent)
|
||||||
|
? 'safari'
|
||||||
|
: /curl\//.test(agent)
|
||||||
|
? 'curl'
|
||||||
|
: 'a browser'
|
||||||
|
const system = /Android/.test(agent)
|
||||||
|
? 'android'
|
||||||
|
: /iPhone|iPad/.test(agent)
|
||||||
|
? 'ios'
|
||||||
|
: /Mac OS X/.test(agent)
|
||||||
|
? 'macos'
|
||||||
|
: /Windows/.test(agent)
|
||||||
|
? 'windows'
|
||||||
|
: /Linux/.test(agent)
|
||||||
|
? 'linux'
|
||||||
|
: ''
|
||||||
|
return system ? `${browser} on ${system}` : browser
|
||||||
|
}
|
||||||
|
|
||||||
const describeBackup = (status: BackupStatus) => {
|
const describeBackup = (status: BackupStatus) => {
|
||||||
if (!status.configured) {
|
if (!status.configured) {
|
||||||
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error')
|
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error')
|
||||||
@@ -782,6 +809,32 @@ export const commands: CommandSpec[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sessions',
|
||||||
|
args: '[rm <n>]',
|
||||||
|
group: 'view',
|
||||||
|
help: 'every browser signed in, numbered — rm signs one of them out, and locks the vault',
|
||||||
|
run: (arg) =>
|
||||||
|
workspace.guard(async () => {
|
||||||
|
const trimmed = arg.trim()
|
||||||
|
const sessions = await api.sessions()
|
||||||
|
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
|
||||||
|
return workspace.say(
|
||||||
|
sessions
|
||||||
|
.map((s, i) => `${i + 1} ${s.current ? 'this one' : browserOf(s.user_agent)}, seen ${relative(s.last_seen)}`)
|
||||||
|
.join(' · '),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rmMatch = /^rm\s+(\d+)$/.exec(trimmed)
|
||||||
|
if (!rmMatch) return workspace.say('usage: :sessions [rm <n>]', 'error')
|
||||||
|
const target = sessions[Number(rmMatch[1]) - 1]
|
||||||
|
if (!target) return workspace.say(`no session ${rmMatch[1]} — :sessions lists them`, 'error')
|
||||||
|
if (target.current) return workspace.say('that is this browser — :logout ends it', 'error')
|
||||||
|
await api.revokeSession(target.id)
|
||||||
|
await workspace.loadVault()
|
||||||
|
workspace.say(`signed out ${browserOf(target.user_agent)}, seen ${relative(target.last_seen)} — the vault is locked`)
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'sshkey',
|
name: 'sshkey',
|
||||||
args: '[add <name> <key> | rm <name> | vault <name> on|off]',
|
args: '[add <name> <key> | rm <name> | vault <name> on|off]',
|
||||||
|
|||||||
Reference in new issue
Block a user