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
+247
-48
@@ -1,4 +1,4 @@
|
||||
use std::convert::Infallible;
|
||||
use std::{collections::HashMap, convert::Infallible, sync::Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{FromRequestParts, OptionalFromRequestParts, Request},
|
||||
@@ -27,36 +27,153 @@ fn sign(secret: &[u8], payload: &str) -> String {
|
||||
B64.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
|
||||
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session.
|
||||
/// A cookie is good until it expires and for as long as the epoch it was
|
||||
/// issued under is the current one.
|
||||
pub fn issue_token(config: &Config, epoch: i64) -> String {
|
||||
let expires = crate::now() + config.session_days * 86_400;
|
||||
let payload = B64.encode(format!("{expires}:{epoch}"));
|
||||
/// A session token is `base64("{expires}:{epoch}:{id}").base64(hmac)`.
|
||||
/// The signature keeps anyone from making one up; the id names the
|
||||
/// [`Sessions`] row it belongs to, and a cookie is good only while that row
|
||||
/// is there, it has not expired, and the epoch it was issued under is the
|
||||
/// current one. Rotating `WORKSPACE_SECRET` still invalidates every session.
|
||||
pub fn issue_token(expires: i64, epoch: i64, id: &str, config: &Config) -> String {
|
||||
let payload = B64.encode(format!("{expires}:{epoch}:{id}"));
|
||||
let signature = sign(&config.secret, &payload);
|
||||
format!("{payload}.{signature}")
|
||||
}
|
||||
|
||||
pub fn verify_token(config: &Config, token: &str, epoch: i64) -> bool {
|
||||
let Some((payload, signature)) = token.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
/// When a session started now runs out.
|
||||
pub fn session_expiry(config: &Config) -> i64 {
|
||||
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);
|
||||
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.
|
||||
@@ -82,6 +199,7 @@ impl Epoch {
|
||||
Some((epoch, previous)) if previous == hash => return Ok(epoch),
|
||||
Some((epoch, _)) => {
|
||||
tracing::info!("the login password changed: every device is signed out");
|
||||
sqlx::query("DELETE FROM sessions").execute(db).await?;
|
||||
epoch + 1
|
||||
}
|
||||
};
|
||||
@@ -96,8 +214,10 @@ impl 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> {
|
||||
state.sessions.end_all(&state.db).await?;
|
||||
let epoch: i64 =
|
||||
sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
|
||||
.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.
|
||||
pub struct Session;
|
||||
pub struct Session {
|
||||
/// The `sessions` row the cookie belongs to.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for Session {
|
||||
type Rejection = AppError;
|
||||
@@ -172,11 +295,22 @@ impl FromRequestParts<AppState> for Session {
|
||||
let token =
|
||||
cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?;
|
||||
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if verify_token(&state.config, token, epoch) {
|
||||
Ok(Session)
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?;
|
||||
let due = state.sessions.seen(&id).ok_or(AppError::Unauthorized)?;
|
||||
if due {
|
||||
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 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;
|
||||
|
||||
@@ -250,24 +385,31 @@ mod tests {
|
||||
Config::for_tests(session_days)
|
||||
}
|
||||
|
||||
fn token(config: &Config, epoch: i64) -> String {
|
||||
issue_token(session_expiry(config), epoch, "sid", config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_token_verifies() {
|
||||
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]
|
||||
fn an_expired_token_does_not() {
|
||||
let config = config(-1);
|
||||
assert!(!verify_token(&config, &issue_token(&config, 0), 0));
|
||||
assert!(verify_token(&config, &token(&config, 0), 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
rotated.secret = b"a-different-secret-entirely".to_vec();
|
||||
assert!(!verify_token(&rotated, &token, 0));
|
||||
assert!(verify_token(&rotated, &token, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -287,25 +429,25 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tampered_token_does_not_verify() {
|
||||
let config = config(30);
|
||||
let token = issue_token(&config, 0);
|
||||
let token = token(&config, 0);
|
||||
let (payload, signature) = token.split_once('.').unwrap();
|
||||
|
||||
// A far-future expiry the holder signed themselves.
|
||||
let forged = base64::Engine::encode(&super::B64, "99999999999");
|
||||
assert!(!verify_token(&config, &format!("{forged}.{signature}"), 0));
|
||||
let forged = base64::Engine::encode(&super::B64, "99999999999:0:sid");
|
||||
assert!(verify_token(&config, &format!("{forged}.{signature}"), 0).is_none());
|
||||
|
||||
// The real expiry with the last byte of the signature flipped.
|
||||
let mut bytes = signature.as_bytes().to_vec();
|
||||
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
|
||||
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]
|
||||
fn malformed_tokens_are_rejected_rather_than_panicking() {
|
||||
let config = config(30);
|
||||
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]
|
||||
fn a_cookie_from_an_earlier_epoch_is_refused() {
|
||||
let config = config(30);
|
||||
let token = issue_token(&config, 3);
|
||||
assert!(verify_token(&config, &token, 3));
|
||||
assert!(!verify_token(&config, &token, 4));
|
||||
let token = token(&config, 3);
|
||||
assert!(verify_token(&config, &token, 3).is_some());
|
||||
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 payload = base64::Engine::encode(&super::B64, expires.to_string());
|
||||
let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload));
|
||||
assert!(verify_token(&config, &legacy, 0));
|
||||
assert!(!verify_token(&config, &legacy, 1));
|
||||
// From before epochs, and from before session rows.
|
||||
for payload in [
|
||||
expires.to_string(),
|
||||
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());
|
||||
|
||||
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);
|
||||
vault.spawn_idle_lock();
|
||||
let state = AppState {
|
||||
@@ -134,6 +135,7 @@ async fn main() -> Result<()> {
|
||||
backup,
|
||||
notify,
|
||||
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
|
||||
sessions,
|
||||
};
|
||||
|
||||
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)),
|
||||
)
|
||||
.route("/logout", post(session::logout))
|
||||
.route("/sessions", get(session::list))
|
||||
.route("/sessions/{id}", delete(session::revoke))
|
||||
.route("/export", get(export::export))
|
||||
.route("/backup", get(backup::status).post(backup::run))
|
||||
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
||||
@@ -140,6 +142,7 @@ pub(crate) mod tests {
|
||||
login_throttle: Arc::default(),
|
||||
vault_throttle: Arc::default(),
|
||||
epoch: Arc::new(AtomicI64::new(0)),
|
||||
sessions: Arc::default(),
|
||||
config,
|
||||
db,
|
||||
}
|
||||
|
||||
+195
-4
@@ -1,10 +1,10 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
@@ -46,9 +46,13 @@ pub async fn login(
|
||||
.unwrap_or("unknown");
|
||||
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(
|
||||
&state.config,
|
||||
expires,
|
||||
state.epoch.load(std::sync::atomic::Ordering::SeqCst),
|
||||
&id,
|
||||
&state.config,
|
||||
);
|
||||
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: 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.
|
||||
if session.is_some() {
|
||||
if let Some(session) = session {
|
||||
state.vault.lock();
|
||||
state.sessions.end(&state.db, &session.id).await?;
|
||||
if query.everywhere {
|
||||
auth::Epoch::advance(&state).await?;
|
||||
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 }))
|
||||
}
|
||||
|
||||
#[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
|
||||
/// session and says nothing about the workspace, only that the server is up
|
||||
/// 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 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)]
|
||||
@@ -23,4 +24,6 @@ pub struct AppState {
|
||||
/// Signed into every session cookie, which must carry the current value
|
||||
/// to be accepted; see [`crate::auth::Epoch`].
|
||||
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> {
|
||||
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
|
||||
// directory is walked, and go in the same pass.
|
||||
let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400;
|
||||
|
||||
Reference in new issue
Block a user