Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
37
crates/mtgc-server/Cargo.toml
Normal file
37
crates/mtgc-server/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "mtgc-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Sync server for the mtgc library catalog: one versioned library per user, OIDC and/or local sign-in."
|
||||
|
||||
[lib]
|
||||
name = "mtgc_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mtgc-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
mtgc-core = { path = "../mtgc-core" }
|
||||
rusqlite = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
axum = { version = "0.8", features = ["form", "json", "query", "tokio", "http1"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "sync"] }
|
||||
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.3"
|
||||
url = "2"
|
||||
rpassword = "7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
ureq = { workspace = true }
|
||||
24
crates/mtgc-server/Dockerfile
Normal file
24
crates/mtgc-server/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# Build from the repository root, so the workspace (and its lockfile) is in
|
||||
# the context:
|
||||
#
|
||||
# docker build -f crates/mtgc-server/Dockerfile -t mtgc-server .
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN cargo build --release --locked -p mtgc-server \
|
||||
&& cp target/release/mtgc-server /mtgc-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --home /data mtgc \
|
||||
&& mkdir /data && chown mtgc /data
|
||||
COPY --from=build /mtgc-server /usr/local/bin/mtgc-server
|
||||
USER mtgc
|
||||
VOLUME /data
|
||||
ENV MTGC_DB=/data/mtgc-server.db \
|
||||
MTGC_BIND=0.0.0.0:8080
|
||||
EXPOSE 8080
|
||||
CMD ["mtgc-server"]
|
||||
16
crates/mtgc-server/deploy/.env.example
Normal file
16
crates/mtgc-server/deploy/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Where users reach the server, exactly as the reverse proxy serves it.
|
||||
MTGC_PUBLIC_URL=https://mtgc.example.com
|
||||
|
||||
# Username/password accounts, managed with `mtgc-server user ...`.
|
||||
# Set to false to allow single sign-on only.
|
||||
MTGC_LOCAL_AUTH=true
|
||||
|
||||
# Single sign-on (optional). Register a confidential OIDC client with the
|
||||
# redirect URI ${MTGC_PUBLIC_URL}/auth/callback.
|
||||
#MTGC_OIDC_ISSUER=https://auth.example.com/application/o/mtgc/
|
||||
#MTGC_OIDC_CLIENT_ID=
|
||||
#MTGC_OIDC_CLIENT_SECRET=
|
||||
#MTGC_OIDC_NAME=Authentik
|
||||
#MTGC_OIDC_SCOPES=openid email profile
|
||||
# Comma-separated; leave unset to let the provider decide who may sign in.
|
||||
#MTGC_ALLOWED_EMAILS=
|
||||
5
crates/mtgc-server/deploy/Caddyfile
Normal file
5
crates/mtgc-server/deploy/Caddyfile
Normal file
@@ -0,0 +1,5 @@
|
||||
# Replace with the hostname in MTGC_PUBLIC_URL. Caddy obtains the certificate.
|
||||
mtgc.example.com {
|
||||
encode gzip
|
||||
reverse_proxy mtgc-server:8080
|
||||
}
|
||||
29
crates/mtgc-server/deploy/compose.yaml
Normal file
29
crates/mtgc-server/deploy/compose.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Example deployment: mtgc-server behind Caddy, which provides HTTPS.
|
||||
#
|
||||
# cp .env.example .env # then fill it in
|
||||
# docker compose up -d
|
||||
# docker compose exec mtgc-server mtgc-server user add yourname
|
||||
|
||||
services:
|
||||
mtgc-server:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: crates/mtgc-server/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- mtgc-data:/data
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
|
||||
volumes:
|
||||
mtgc-data:
|
||||
caddy-data:
|
||||
186
crates/mtgc-server/src/auth/local.rs
Normal file
186
crates/mtgc-server/src/auth/local.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
//! Username/password accounts, for servers without an identity provider.
|
||||
//!
|
||||
//! Accounts are created by whoever runs the server (`mtgc-server user add`);
|
||||
//! there is no sign-up page, so a server exposed to the internet can't be
|
||||
//! filled with accounts by strangers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
use axum::extract::{Form, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::Response;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{auth, db, pages, AppState};
|
||||
|
||||
/// Failures allowed before each further attempt has to wait.
|
||||
const FREE_FAILURES: u32 = 5;
|
||||
const MAX_DELAY: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
pub struct LocalAuth {
|
||||
/// A real hash of nothing, verified against when the username doesn't
|
||||
/// exist, so a wrong username takes as long to reject as a wrong password
|
||||
/// and response times don't reveal which accounts exist.
|
||||
dummy_hash: String,
|
||||
failures: Mutex<HashMap<String, Failures>>,
|
||||
}
|
||||
|
||||
struct Failures {
|
||||
count: u32,
|
||||
locked_until: Instant,
|
||||
}
|
||||
|
||||
impl LocalAuth {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
dummy_hash: hash_password("not a real password").expect("hashing works"),
|
||||
failures: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// How long until this username may try again, if it must wait.
|
||||
fn wait(&self, username: &str) -> Option<Duration> {
|
||||
let map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let f = map.get(username)?;
|
||||
f.locked_until.checked_duration_since(Instant::now())
|
||||
}
|
||||
|
||||
fn record_failure(&self, username: &str) {
|
||||
let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let f = map
|
||||
.entry(username.to_string())
|
||||
.or_insert(Failures { count: 0, locked_until: Instant::now() });
|
||||
f.count += 1;
|
||||
if f.count >= FREE_FAILURES {
|
||||
// 1s, 2s, 4s, ... capped. Slows guessing to a crawl without
|
||||
// locking the real owner out for long.
|
||||
let exp = (f.count - FREE_FAILURES).min(20);
|
||||
let delay = Duration::from_secs(1u64 << exp).min(MAX_DELAY);
|
||||
f.locked_until = Instant::now() + delay;
|
||||
}
|
||||
}
|
||||
|
||||
fn record_success(&self, username: &str) {
|
||||
self.failures.lock().unwrap_or_else(|e| e.into_inner()).remove(username);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalAuth {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Argon2id with the crate's defaults (19 MiB, 2 passes): OWASP's baseline.
|
||||
pub fn hash_password(password: &str) -> Result<String, String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn verify_password(password: &str, hash: &str) -> bool {
|
||||
PasswordHash::new(hash)
|
||||
.map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Usernames are compared case-insensitively, as people expect them to be.
|
||||
pub fn normalize_username(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pending: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// The sign-in form's target.
|
||||
pub async fn submit(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
if !state.config().local_auth {
|
||||
return pages::error(StatusCode::NOT_FOUND, "Password sign-in is turned off on this server.");
|
||||
}
|
||||
// The form only exists inside a sign-in the app started, in this browser;
|
||||
// without one there is nowhere to send the result.
|
||||
let pending = match auth::bound_pending(&state, &headers, &form.pending) {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let oidc_name = state.config().oidc.as_ref().map(|o| o.display_name.clone());
|
||||
let username = normalize_username(&form.username);
|
||||
|
||||
if let Some(wait) = state.0.local.wait(&username) {
|
||||
let msg = format!("Too many attempts. Try again in {} seconds.", wait.as_secs().max(1));
|
||||
return pages::login(&pending, oidc_name.as_deref(), Some(&msg), &form.username);
|
||||
}
|
||||
|
||||
let account = match db::local_user(&state.db(), &username) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::error!("load local user: {e}");
|
||||
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
|
||||
}
|
||||
};
|
||||
|
||||
// Hashing is deliberately slow; keep it off the async workers.
|
||||
let hash = account
|
||||
.as_ref()
|
||||
.map(|(_, h)| h.clone())
|
||||
.unwrap_or_else(|| state.0.local.dummy_hash.clone());
|
||||
let password = form.password.clone();
|
||||
let ok = tokio::task::spawn_blocking(move || verify_password(&password, &hash))
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
match account {
|
||||
Some((user, _)) if ok => {
|
||||
state.0.local.record_success(&username);
|
||||
auth::finish(&state, &pending, &user)
|
||||
}
|
||||
_ => {
|
||||
state.0.local.record_failure(&username);
|
||||
tracing::warn!(username = %username, "failed password sign-in");
|
||||
pages::login(&pending, oidc_name.as_deref(), Some("Invalid username or password."), &form.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_password_verifies_against_its_own_hash_only() {
|
||||
let h = hash_password("correct horse").unwrap();
|
||||
assert!(h.starts_with("$argon2id$"));
|
||||
assert!(verify_password("correct horse", &h));
|
||||
assert!(!verify_password("wrong", &h));
|
||||
assert!(!verify_password("correct horse", "not a hash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_failures_make_a_username_wait() {
|
||||
let a = LocalAuth::new();
|
||||
for _ in 0..FREE_FAILURES - 1 {
|
||||
a.record_failure("me");
|
||||
}
|
||||
assert!(a.wait("me").is_none(), "a few typos are free");
|
||||
a.record_failure("me");
|
||||
assert!(a.wait("me").is_some());
|
||||
assert!(a.wait("someone-else").is_none(), "only that username waits");
|
||||
a.record_success("me");
|
||||
assert!(a.wait("me").is_none());
|
||||
}
|
||||
}
|
||||
304
crates/mtgc-server/src/auth/mod.rs
Normal file
304
crates/mtgc-server/src/auth/mod.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
//! Signing a device in, and recognising it afterwards.
|
||||
//!
|
||||
//! Every sign-in follows the same shape, whichever way the user proves who
|
||||
//! they are:
|
||||
//!
|
||||
//! 1. The app opens the browser at `/auth/start`, with where to send the user
|
||||
//! back to (`redirect_uri`, an app-only address), a PKCE challenge, and its
|
||||
//! own `state`. That becomes a *pending login*.
|
||||
//! 2. The user signs in on this server's page ([`local`]) or at the identity
|
||||
//! provider ([`oidc`]).
|
||||
//! 3. Either way ends in [`finish`]: a single-use code is sent back to the app
|
||||
//! at its `redirect_uri`.
|
||||
//! 4. The app redeems the code with its PKCE verifier at `/auth/token` and gets
|
||||
//! a token for that device alone.
|
||||
//!
|
||||
//! PKCE is what makes step 3 safe on a phone: another app that registered the
|
||||
//! same URL scheme could receive the code, but can't redeem it without the
|
||||
//! verifier, which never left the app that started the sign-in.
|
||||
//!
|
||||
//! Devices hold long-lived tokens rather than the provider's own, because those
|
||||
//! expire within minutes and a device that has been offline for a week must
|
||||
//! still be able to sync the moment it reconnects. Each is revocable on its own.
|
||||
|
||||
pub mod local;
|
||||
pub mod oidc;
|
||||
|
||||
use axum::extract::{FromRequestParts, Path, Query, State};
|
||||
use axum::http::header::{COOKIE, SET_COOKIE};
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::db::{self, PendingLogin, User};
|
||||
use crate::{pages, random_token, sha256_hex, ApiError, AppState};
|
||||
|
||||
/// How long the user has to finish signing in once the browser opens.
|
||||
const PENDING_TTL_SECS: i64 = 15 * 60;
|
||||
/// How long the app has to redeem its code. It does so immediately.
|
||||
const CODE_TTL_SECS: i64 = 120;
|
||||
|
||||
/// The only places a code may be sent: the desktop app's one-shot listener on
|
||||
/// the loopback interface (any port, RFC 8252 §7.3), or the phone app's URL
|
||||
/// scheme. Anything else is refused outright rather than redirected to, so the
|
||||
/// server can never be used to deliver a code to a website.
|
||||
pub fn redirect_allowed(uri: &str) -> bool {
|
||||
if uri == "mtgc://auth/callback" {
|
||||
return true;
|
||||
}
|
||||
let Ok(u) = url::Url::parse(uri) else {
|
||||
return false;
|
||||
};
|
||||
u.scheme() == "http"
|
||||
&& u.host_str() == Some("127.0.0.1")
|
||||
&& u.port().is_some()
|
||||
&& u.path() == "/callback"
|
||||
&& u.query().is_none()
|
||||
&& u.fragment().is_none()
|
||||
&& u.username().is_empty()
|
||||
&& u.password().is_none()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StartParams {
|
||||
redirect_uri: String,
|
||||
code_challenge: String,
|
||||
#[serde(default)]
|
||||
code_challenge_method: Option<String>,
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
device_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Step 1: begin a sign-in and show the way to complete it.
|
||||
pub async fn start(State(state): State<AppState>, Query(p): Query<StartParams>) -> Response {
|
||||
if !redirect_allowed(&p.redirect_uri) {
|
||||
return pages::error(StatusCode::BAD_REQUEST, "This sign-in link didn't come from the mtgc app.");
|
||||
}
|
||||
if p.code_challenge_method.as_deref().unwrap_or("S256") != "S256" || p.code_challenge.len() < 43 {
|
||||
return pages::error(StatusCode::BAD_REQUEST, "This version of the app isn't supported — update it.");
|
||||
}
|
||||
let device_name = p
|
||||
.device_name
|
||||
.map(|n| n.trim().chars().take(80).collect::<String>())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| "Unnamed device".into());
|
||||
|
||||
let pending = PendingLogin {
|
||||
id: random_token(),
|
||||
redirect_uri: p.redirect_uri,
|
||||
app_state: p.state.chars().take(200).collect(),
|
||||
code_challenge: p.code_challenge,
|
||||
device_name,
|
||||
oidc_nonce: None,
|
||||
oidc_verifier: None,
|
||||
};
|
||||
if let Err(e) = db::create_pending(&state.db(), &pending, PENDING_TTL_SECS) {
|
||||
tracing::error!("create pending login: {e}");
|
||||
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
|
||||
}
|
||||
|
||||
let c = state.config();
|
||||
let mut resp = if c.local_auth {
|
||||
pages::login(&pending, c.oidc.as_ref().map(|o| o.display_name.as_str()), None, "")
|
||||
} else {
|
||||
// Nothing to choose between: go straight to the provider.
|
||||
Redirect::to(&format!("/auth/oidc?pending={}", pending.id)).into_response()
|
||||
};
|
||||
let secure = if c.public_url.starts_with("https://") { "; Secure" } else { "" };
|
||||
let cookie = format!(
|
||||
"{LOGIN_COOKIE}={}; Path=/auth; HttpOnly; SameSite=Lax; Max-Age={PENDING_TTL_SECS}{secure}",
|
||||
pending.id
|
||||
);
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie) {
|
||||
resp.headers_mut().append(SET_COOKIE, v);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Ties a sign-in to the browser it started in. The pending login's id already
|
||||
/// travels in the form and in the provider's `state`, but requiring it in a
|
||||
/// cookie as well means a link or form planted on another site can't carry
|
||||
/// someone's browser through a sign-in it didn't start.
|
||||
const LOGIN_COOKIE: &str = "mtgc_login";
|
||||
|
||||
/// The pending login `id`, provided this browser is the one that started it.
|
||||
/// Fails with the page to show instead.
|
||||
#[allow(clippy::result_large_err)] // once per sign-in step; boxing buys nothing
|
||||
pub(crate) fn bound_pending(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
id: &str,
|
||||
) -> Result<PendingLogin, Response> {
|
||||
let expired = || {
|
||||
pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.")
|
||||
};
|
||||
let from_cookie = headers
|
||||
.get_all(COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|c| c.trim().split_once('='))
|
||||
.find(|(k, _)| *k == LOGIN_COOKIE)
|
||||
.map(|(_, v)| v.to_string());
|
||||
if id.is_empty() || from_cookie.as_deref() != Some(id) {
|
||||
return Err(expired());
|
||||
}
|
||||
match db::pending(&state.db(), id) {
|
||||
Ok(Some(p)) => Ok(p),
|
||||
Ok(None) => Err(expired()),
|
||||
Err(e) => {
|
||||
tracing::error!("load pending login: {e}");
|
||||
Err(pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 3: the user is who they say they are. Mint the app's code and send the
|
||||
/// browser back to it.
|
||||
pub(crate) fn finish(state: &AppState, pending: &PendingLogin, user: &User) -> Response {
|
||||
let code = random_token();
|
||||
let stored = {
|
||||
let conn = state.db();
|
||||
db::create_auth_code(
|
||||
&conn,
|
||||
&sha256_hex(&code),
|
||||
user.id,
|
||||
&pending.code_challenge,
|
||||
&pending.device_name,
|
||||
CODE_TTL_SECS,
|
||||
)
|
||||
.and_then(|_| db::delete_pending(&conn, &pending.id))
|
||||
};
|
||||
if let Err(e) = stored {
|
||||
tracing::error!("store auth code: {e}");
|
||||
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
|
||||
}
|
||||
tracing::info!(user = %user.sub, device = %pending.device_name, "signed in");
|
||||
pages::return_to_app(&back_to_app(pending, &[("code", &code)]))
|
||||
}
|
||||
|
||||
/// The app's redirect URI with `params` and its own `state` attached.
|
||||
pub(crate) fn back_to_app(pending: &PendingLogin, params: &[(&str, &str)]) -> String {
|
||||
let mut u = url::Url::parse(&pending.redirect_uri).expect("validated in start()");
|
||||
{
|
||||
let mut q = u.query_pairs_mut();
|
||||
for (k, v) in params {
|
||||
q.append_pair(k, v);
|
||||
}
|
||||
q.append_pair("state", &pending.app_state);
|
||||
}
|
||||
u.into()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
code: String,
|
||||
code_verifier: String,
|
||||
}
|
||||
|
||||
/// Step 4: trade the code and PKCE verifier for this device's token.
|
||||
pub async fn token(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<TokenRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let invalid = || ApiError::new(StatusCode::BAD_REQUEST, "sign-in code is invalid or expired");
|
||||
|
||||
let conn = state.db();
|
||||
let (user_id, challenge, device_name) =
|
||||
db::take_auth_code(&conn, &sha256_hex(&req.code))?.ok_or_else(invalid)?;
|
||||
if pkce_challenge(&req.code_verifier) != challenge {
|
||||
return Err(invalid());
|
||||
}
|
||||
let user = db::user_by_id(&conn, user_id)?.ok_or_else(invalid)?;
|
||||
|
||||
let token = format!("mtgc_{}", random_token());
|
||||
let device_id = db::create_device(&conn, user.id, &sha256_hex(&token), &device_name)?;
|
||||
Ok(Json(json!({
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"user": user_json(&user),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// S256 per RFC 7636: base64url(SHA-256(verifier)), unpadded.
|
||||
pub fn pkce_challenge(verifier: &str) -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||||
}
|
||||
|
||||
fn user_json(u: &User) -> serde_json::Value {
|
||||
json!({
|
||||
"id": u.public_id,
|
||||
"username": u.sub,
|
||||
"display_name": u.display_name,
|
||||
"email": u.email,
|
||||
"method": if u.iss == db::LOCAL_ISS { "local" } else { "oidc" },
|
||||
})
|
||||
}
|
||||
|
||||
/// A request from a signed-in device.
|
||||
pub struct Authed {
|
||||
pub user: User,
|
||||
pub device_id: i64,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for Authed {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
|
||||
let token = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.ok_or_else(ApiError::unauthorized)?;
|
||||
let conn = state.db();
|
||||
let device = db::device_by_token(&conn, &sha256_hex(token))?.ok_or_else(ApiError::unauthorized)?;
|
||||
let user = db::user_by_id(&conn, device.user_id)?.ok_or_else(ApiError::unauthorized)?;
|
||||
Ok(Self { user, device_id: device.id })
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed-in account and its devices.
|
||||
pub async fn me(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
let devices = db::devices_for(&state.db(), auth.user.id)?;
|
||||
let devices: Vec<_> = devices
|
||||
.iter()
|
||||
.map(|d| {
|
||||
json!({
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"created_at": d.created_at,
|
||||
"last_seen_at": d.last_seen_at,
|
||||
"current": d.id == auth.device_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({ "user": user_json(&auth.user), "devices": devices })).into_response())
|
||||
}
|
||||
|
||||
/// Sign this device out.
|
||||
pub async fn logout(State(state): State<AppState>, auth: Authed) -> Result<StatusCode, ApiError> {
|
||||
db::delete_device(&state.db(), auth.user.id, auth.device_id)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Sign out another of the user's devices — a lost phone, say.
|
||||
pub async fn revoke_device(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if db::delete_device(&state.db(), auth.user.id, id)? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(ApiError::new(StatusCode::NOT_FOUND, "no such device"))
|
||||
}
|
||||
}
|
||||
266
crates/mtgc-server/src/auth/oidc.rs
Normal file
266
crates/mtgc-server/src/auth/oidc.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! Signing in through the server owner's OpenID Connect provider.
|
||||
//!
|
||||
//! The server is a confidential client: it holds the client secret, makes the
|
||||
//! code exchange itself, and checks the ID token (signature, issuer, audience,
|
||||
//! nonce). The provider's tokens are used once, to learn who the user is, and
|
||||
//! then discarded — devices get their own tokens (see [`super`]).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
|
||||
use openidconnect::{
|
||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
|
||||
PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::OidcConfig;
|
||||
use crate::{auth, db, pages, AppState};
|
||||
|
||||
/// How long discovered provider metadata (including its signing keys) is
|
||||
/// trusted before being fetched again, so a key rotation at the provider is
|
||||
/// picked up without restarting the server.
|
||||
const METADATA_TTL: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
pub struct Oidc {
|
||||
config: OidcConfig,
|
||||
http: openidconnect::reqwest::Client,
|
||||
metadata: tokio::sync::Mutex<Option<(Instant, CoreProviderMetadata)>>,
|
||||
}
|
||||
|
||||
impl Oidc {
|
||||
pub fn new(config: OidcConfig) -> anyhow::Result<Self> {
|
||||
IssuerUrl::new(config.issuer.clone())?;
|
||||
let http = openidconnect::reqwest::ClientBuilder::new()
|
||||
// Following redirects from the token endpoint would let a
|
||||
// misbehaving provider bounce the client secret elsewhere.
|
||||
.redirect(openidconnect::reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { config, http, metadata: tokio::sync::Mutex::new(None) })
|
||||
}
|
||||
|
||||
/// The provider's metadata, discovered lazily so the server starts (and
|
||||
/// password sign-in keeps working) even while the provider is down.
|
||||
async fn metadata(&self, force: bool) -> anyhow::Result<CoreProviderMetadata> {
|
||||
let mut cached = self.metadata.lock().await;
|
||||
if let Some((at, meta)) = cached.as_ref() {
|
||||
if !force && at.elapsed() < METADATA_TTL {
|
||||
return Ok(meta.clone());
|
||||
}
|
||||
}
|
||||
let meta =
|
||||
CoreProviderMetadata::discover_async(IssuerUrl::new(self.config.issuer.clone())?, &self.http)
|
||||
.await?;
|
||||
*cached = Some((Instant::now(), meta.clone()));
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn client(
|
||||
&self,
|
||||
meta: CoreProviderMetadata,
|
||||
redirect_uri: String,
|
||||
) -> anyhow::Result<
|
||||
CoreClient<
|
||||
openidconnect::EndpointSet,
|
||||
openidconnect::EndpointNotSet,
|
||||
openidconnect::EndpointNotSet,
|
||||
openidconnect::EndpointNotSet,
|
||||
openidconnect::EndpointMaybeSet,
|
||||
openidconnect::EndpointMaybeSet,
|
||||
>,
|
||||
> {
|
||||
Ok(CoreClient::from_provider_metadata(
|
||||
meta,
|
||||
ClientId::new(self.config.client_id.clone()),
|
||||
Some(ClientSecret::new(self.config.client_secret.clone())),
|
||||
)
|
||||
.set_redirect_uri(RedirectUrl::new(redirect_uri)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BeginParams {
|
||||
pending: String,
|
||||
}
|
||||
|
||||
/// Send the browser to the provider for a sign-in the app started.
|
||||
pub async fn begin(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(p): Query<BeginParams>,
|
||||
) -> Response {
|
||||
let Some(oidc) = state.0.oidc.as_ref() else {
|
||||
return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server.");
|
||||
};
|
||||
let pending = match auth::bound_pending(&state, &headers, &p.pending) {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let meta = match oidc.metadata(false).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC discovery failed: {e:#}");
|
||||
return pages::error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"The sign-in provider isn't reachable right now. Try again shortly.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let client = match oidc.client(meta, state.config().oidc_redirect_uri()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC client: {e:#}");
|
||||
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Single sign-on is misconfigured.");
|
||||
}
|
||||
};
|
||||
|
||||
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
let pending_id = pending.id.clone();
|
||||
let mut req = client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
// The pending login's id is the state: it is what ties the
|
||||
// provider's answer back to this sign-in.
|
||||
move || CsrfToken::new(pending_id),
|
||||
Nonce::new_random,
|
||||
)
|
||||
.set_pkce_challenge(challenge);
|
||||
for scope in &oidc.config.scopes {
|
||||
req = req.add_scope(Scope::new(scope.clone()));
|
||||
}
|
||||
let (url, _state, nonce) = req.url();
|
||||
|
||||
if let Err(e) = db::set_pending_oidc(&state.db(), &pending.id, nonce.secret(), verifier.secret()) {
|
||||
tracing::error!("store OIDC nonce: {e}");
|
||||
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
|
||||
}
|
||||
Redirect::to(url.as_str()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallbackParams {
|
||||
state: Option<String>,
|
||||
code: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// The provider's answer.
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(p): Query<CallbackParams>,
|
||||
) -> Response {
|
||||
let Some(oidc) = state.0.oidc.as_ref() else {
|
||||
return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server.");
|
||||
};
|
||||
let pending = match auth::bound_pending(&state, &headers, p.state.as_deref().unwrap_or_default()) {
|
||||
Ok(p) => p,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
if let Some(err) = p.error {
|
||||
// Declined at the provider, or the provider refused. Tell the app, so
|
||||
// it stops waiting, rather than leaving the user on a dead end here.
|
||||
tracing::info!("OIDC sign-in ended with {err}");
|
||||
let _ = db::delete_pending(&state.db(), &pending.id);
|
||||
return pages::return_to_app(&auth::back_to_app(&pending, &[("error", "access_denied")]));
|
||||
}
|
||||
let (Some(code), Some(nonce), Some(verifier)) =
|
||||
(p.code, pending.oidc_nonce.clone(), pending.oidc_verifier.clone())
|
||||
else {
|
||||
return pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.");
|
||||
};
|
||||
|
||||
let identity = match identify(&state, oidc, code, nonce, verifier).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
tracing::warn!("OIDC sign-in failed: {e:#}");
|
||||
return pages::error(StatusCode::BAD_GATEWAY, "Sign-in couldn't be completed. Start again from the app.");
|
||||
}
|
||||
};
|
||||
|
||||
let allowed = &oidc.config.allowed_emails;
|
||||
if !allowed.is_empty() {
|
||||
let email = identity.email.to_lowercase();
|
||||
if !identity.email_verified || !allowed.contains(&email) {
|
||||
tracing::warn!(sub = %identity.sub, email = %email, "OIDC sign-in not on the allow list");
|
||||
return pages::error(StatusCode::FORBIDDEN, "This account isn't allowed to use this server.");
|
||||
}
|
||||
}
|
||||
|
||||
let user = match db::upsert_user(
|
||||
&state.db(),
|
||||
&identity.iss,
|
||||
&identity.sub,
|
||||
&identity.display_name,
|
||||
&identity.email,
|
||||
) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("upsert OIDC user: {e}");
|
||||
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
|
||||
}
|
||||
};
|
||||
auth::finish(&state, &pending, &user)
|
||||
}
|
||||
|
||||
struct Identity {
|
||||
iss: String,
|
||||
sub: String,
|
||||
display_name: String,
|
||||
email: String,
|
||||
email_verified: bool,
|
||||
}
|
||||
|
||||
async fn identify(
|
||||
state: &AppState,
|
||||
oidc: &Oidc,
|
||||
code: String,
|
||||
nonce: String,
|
||||
verifier: String,
|
||||
) -> anyhow::Result<Identity> {
|
||||
let meta = oidc.metadata(false).await?;
|
||||
let client = oidc.client(meta, state.config().oidc_redirect_uri())?;
|
||||
let response = client
|
||||
.exchange_code(AuthorizationCode::new(code))?
|
||||
.set_pkce_verifier(PkceCodeVerifier::new(verifier))
|
||||
.request_async(&oidc.http)
|
||||
.await?;
|
||||
let id_token = response
|
||||
.id_token()
|
||||
.ok_or_else(|| anyhow::anyhow!("the provider returned no ID token"))?;
|
||||
|
||||
let nonce = Nonce::new(nonce);
|
||||
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||
Ok(c) => c.clone(),
|
||||
Err(first) => {
|
||||
// Perhaps the provider rotated its signing keys since we last
|
||||
// looked. Refetch once before giving up.
|
||||
tracing::info!("ID token didn't verify ({first}); refreshing provider keys");
|
||||
let meta = oidc.metadata(true).await?;
|
||||
let client = oidc.client(meta, state.config().oidc_redirect_uri())?;
|
||||
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?.clone();
|
||||
claims
|
||||
}
|
||||
};
|
||||
|
||||
let email = claims.email().map(|e| e.as_str().to_string()).unwrap_or_default();
|
||||
let display_name = claims
|
||||
.name()
|
||||
.and_then(|n| n.get(None))
|
||||
.map(|n| n.as_str().to_string())
|
||||
.or_else(|| claims.preferred_username().map(|u| u.as_str().to_string()))
|
||||
.unwrap_or_else(|| email.clone());
|
||||
Ok(Identity {
|
||||
iss: claims.issuer().as_str().to_string(),
|
||||
sub: claims.subject().as_str().to_string(),
|
||||
display_name,
|
||||
email_verified: claims.email_verified().unwrap_or(false),
|
||||
email,
|
||||
})
|
||||
}
|
||||
172
crates/mtgc-server/src/config.rs
Normal file
172
crates/mtgc-server/src/config.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Server configuration, read from the environment.
|
||||
//!
|
||||
//! Environment variables rather than a file because the server is meant to run
|
||||
//! in a container behind a reverse proxy, where that is the native way to
|
||||
//! configure things — and there are few enough settings that a file would be
|
||||
//! all ceremony.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// The URL users reach the server at, as the reverse proxy serves it. Used
|
||||
/// to build the OIDC redirect URI, so it must match what the provider has
|
||||
/// registered exactly.
|
||||
pub public_url: String,
|
||||
pub bind: SocketAddr,
|
||||
pub db_path: PathBuf,
|
||||
/// Username/password accounts, managed with `mtgc-server user ...`.
|
||||
pub local_auth: bool,
|
||||
pub oidc: Option<OidcConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcConfig {
|
||||
pub issuer: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub scopes: Vec<String>,
|
||||
/// Shown on the sign-in button: "Sign in with <name>".
|
||||
pub display_name: String,
|
||||
/// When non-empty, only these (verified) addresses may sign in. Empty means
|
||||
/// the provider alone decides who gets in, which is usually right: limiting
|
||||
/// access to the application is a provider-side setting in Authentik and
|
||||
/// Keycloak alike.
|
||||
pub allowed_emails: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Self::from_lookup(|k| std::env::var(k).ok())
|
||||
}
|
||||
|
||||
/// Read settings through `get`, so tests can supply them without touching
|
||||
/// the process environment.
|
||||
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||
let get = |k: &str| get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
|
||||
let public_url = get("MTGC_PUBLIC_URL")
|
||||
.context("MTGC_PUBLIC_URL is required — the https:// address the server is reached at")?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
url::Url::parse(&public_url).context("MTGC_PUBLIC_URL isn't a valid URL")?;
|
||||
|
||||
let bind = get("MTGC_BIND")
|
||||
.unwrap_or_else(|| "0.0.0.0:8080".into())
|
||||
.parse()
|
||||
.context("MTGC_BIND must look like 0.0.0.0:8080")?;
|
||||
let db_path = PathBuf::from(get("MTGC_DB").unwrap_or_else(|| "mtgc-server.db".into()));
|
||||
|
||||
let local_auth = match get("MTGC_LOCAL_AUTH").as_deref() {
|
||||
None => true,
|
||||
Some(v) => parse_bool(v).context("MTGC_LOCAL_AUTH must be true or false")?,
|
||||
};
|
||||
|
||||
let oidc = match get("MTGC_OIDC_ISSUER") {
|
||||
None => None,
|
||||
Some(issuer) => Some(OidcConfig {
|
||||
issuer,
|
||||
client_id: get("MTGC_OIDC_CLIENT_ID")
|
||||
.context("MTGC_OIDC_CLIENT_ID is required when MTGC_OIDC_ISSUER is set")?,
|
||||
client_secret: get("MTGC_OIDC_CLIENT_SECRET")
|
||||
.context("MTGC_OIDC_CLIENT_SECRET is required when MTGC_OIDC_ISSUER is set")?,
|
||||
scopes: get("MTGC_OIDC_SCOPES")
|
||||
.unwrap_or_else(|| "openid email profile".into())
|
||||
.split_whitespace()
|
||||
.filter(|s| *s != "openid") // always requested
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
display_name: get("MTGC_OIDC_NAME").unwrap_or_else(|| "single sign-on".into()),
|
||||
allowed_emails: get("MTGC_ALLOWED_EMAILS")
|
||||
.map(|v| {
|
||||
v.split(',')
|
||||
.map(|e| e.trim().to_lowercase())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
};
|
||||
|
||||
if !local_auth && oidc.is_none() {
|
||||
bail!(
|
||||
"no way to sign in: set MTGC_OIDC_ISSUER (and its client settings), \
|
||||
or leave MTGC_LOCAL_AUTH enabled"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self { public_url, bind, db_path, local_auth, oidc })
|
||||
}
|
||||
|
||||
pub fn oidc_redirect_uri(&self) -> String {
|
||||
format!("{}/auth/callback", self.public_url)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(v: &str) -> Option<bool> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn cfg(vars: &[(&str, &str)]) -> anyhow::Result<Config> {
|
||||
let map: HashMap<String, String> =
|
||||
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
Config::from_lookup(|k| map.get(k).cloned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_accounts_alone_need_only_the_public_url() {
|
||||
let c = cfg(&[("MTGC_PUBLIC_URL", "https://mtgc.example/")]).unwrap();
|
||||
assert!(c.local_auth);
|
||||
assert!(c.oidc.is_none());
|
||||
assert_eq!(c.oidc_redirect_uri(), "https://mtgc.example/auth/callback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_every_sign_in_method_is_refused() {
|
||||
let err = cfg(&[("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_LOCAL_AUTH", "false")])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no way to sign in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_needs_its_client_settings() {
|
||||
let err = cfg(&[
|
||||
("MTGC_PUBLIC_URL", "https://m.example"),
|
||||
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("MTGC_OIDC_CLIENT_ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_settings_are_read() {
|
||||
let c = cfg(&[
|
||||
("MTGC_PUBLIC_URL", "https://m.example"),
|
||||
("MTGC_LOCAL_AUTH", "off"),
|
||||
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
|
||||
("MTGC_OIDC_CLIENT_ID", "mtgc"),
|
||||
("MTGC_OIDC_CLIENT_SECRET", "s3cret"),
|
||||
("MTGC_OIDC_NAME", "Authentik"),
|
||||
("MTGC_ALLOWED_EMAILS", "Me@Example.com, you@example.com"),
|
||||
])
|
||||
.unwrap();
|
||||
let o = c.oidc.unwrap();
|
||||
assert!(!c.local_auth);
|
||||
assert_eq!(o.scopes, vec!["email", "profile"]);
|
||||
assert_eq!(o.allowed_emails, vec!["me@example.com", "you@example.com"]);
|
||||
assert_eq!(o.display_name, "Authentik");
|
||||
}
|
||||
}
|
||||
450
crates/mtgc-server/src/db.rs
Normal file
450
crates/mtgc-server/src/db.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
//! The server's SQLite database: accounts, devices, sign-ins in progress, and
|
||||
//! each user's library.
|
||||
//!
|
||||
//! Everything here is synchronous and runs under the one connection mutex in
|
||||
//! [`crate::AppState`]. Every statement is a quick indexed lookup or a
|
||||
//! single-row write, so serialising them costs nothing at the scale of a
|
||||
//! personal server and rules out a whole class of races — most importantly in
|
||||
//! [`put_library`], whose compare-and-swap is only sound because nothing else
|
||||
//! can write between its check and its update.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::now;
|
||||
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
// 1: initial schema
|
||||
"CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- What clients see as the account id. Random rather than the rowid so
|
||||
-- a rebuilt database can't hand a device's old id to someone else.
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
-- 'local' for username/password accounts (sub = username); the OIDC
|
||||
-- issuer URL otherwise.
|
||||
iss TEXT NOT NULL,
|
||||
sub TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (iss, sub)
|
||||
);
|
||||
CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE pending_logins (
|
||||
id TEXT PRIMARY KEY,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
app_state TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
oidc_nonce TEXT,
|
||||
oidc_verifier TEXT,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE auth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_challenge TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE libraries (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
doc TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE library_history (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
doc TEXT NOT NULL,
|
||||
saved_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, version)
|
||||
);",
|
||||
];
|
||||
|
||||
/// How many superseded versions of each library are kept. A safety net for
|
||||
/// recovering from a bad merge or a misbehaving client by hand, not a feature
|
||||
/// the app exposes; a few dozen covers days of normal use.
|
||||
pub const HISTORY_KEEP: i64 = 50;
|
||||
|
||||
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
init(conn)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> rusqlite::Result<Connection> {
|
||||
init(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
fn init(conn: Connection) -> rusqlite::Result<Connection> {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
let current: usize = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||
for (i, sql) in MIGRATIONS.iter().enumerate().skip(current) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(sql)?;
|
||||
tx.pragma_update(None, "user_version", i + 1)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
// --- users ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub public_id: String,
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
const USER_COLS: &str = "id, public_id, iss, sub, display_name, email";
|
||||
|
||||
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
|
||||
Ok(User {
|
||||
id: r.get(0)?,
|
||||
public_id: r.get(1)?,
|
||||
iss: r.get(2)?,
|
||||
sub: r.get(3)?,
|
||||
display_name: r.get(4)?,
|
||||
email: r.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub const LOCAL_ISS: &str = "local";
|
||||
|
||||
/// Create the user for an identity, or refresh the profile details of the one
|
||||
/// that already exists. The identity itself (`iss`, `sub`) never changes.
|
||||
pub fn upsert_user(
|
||||
conn: &Connection,
|
||||
iss: &str,
|
||||
sub: &str,
|
||||
display_name: &str,
|
||||
email: &str,
|
||||
) -> rusqlite::Result<User> {
|
||||
conn.execute(
|
||||
"INSERT INTO users (public_id, iss, sub, display_name, email, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT (iss, sub) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
email = excluded.email",
|
||||
params![crate::random_hex(16), iss, sub, display_name, email, now()],
|
||||
)?;
|
||||
conn.query_row(
|
||||
&format!("SELECT {USER_COLS} FROM users WHERE iss = ?1 AND sub = ?2"),
|
||||
params![iss, sub],
|
||||
user_row,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<User>> {
|
||||
conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// A local account and its password hash.
|
||||
pub fn local_user(conn: &Connection, username: &str) -> rusqlite::Result<Option<(User, String)>> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT {USER_COLS}, password_hash FROM users
|
||||
WHERE iss = '{LOCAL_ISS}' AND sub = ?1 AND password_hash IS NOT NULL"
|
||||
),
|
||||
[username],
|
||||
|r| Ok((user_row(r)?, r.get(6)?)),
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Create a local account. Fails if the username is taken.
|
||||
pub fn create_local_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
display_name: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<User> {
|
||||
conn.execute(
|
||||
"INSERT INTO users (public_id, iss, sub, display_name, password_hash, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![crate::random_hex(16), LOCAL_ISS, username, display_name, password_hash, now()],
|
||||
)?;
|
||||
Ok(local_user(conn, username)?.expect("just inserted").0)
|
||||
}
|
||||
|
||||
/// Returns whether the account exists.
|
||||
pub fn set_local_password(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
Ok(conn.execute(
|
||||
"UPDATE users SET password_hash = ?2 WHERE iss = 'local' AND sub = ?1",
|
||||
params![username, password_hash],
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
/// Delete a local account along with its devices and library. Returns whether
|
||||
/// it existed.
|
||||
pub fn delete_local_user(conn: &Connection, username: &str) -> rusqlite::Result<bool> {
|
||||
Ok(conn.execute("DELETE FROM users WHERE iss = 'local' AND sub = ?1", [username])? > 0)
|
||||
}
|
||||
|
||||
/// Every account, for `mtgc-server user list`: (user, device count).
|
||||
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<(User, i64)>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id)
|
||||
FROM users ORDER BY iss, sub"
|
||||
))?;
|
||||
let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?)))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// --- devices ----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Device {
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
pub created_at: i64,
|
||||
pub last_seen_at: i64,
|
||||
}
|
||||
|
||||
pub fn create_device(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
token_hash: &str,
|
||||
name: &str,
|
||||
) -> rusqlite::Result<i64> {
|
||||
let t = now();
|
||||
conn.execute(
|
||||
"INSERT INTO devices (user_id, token_hash, name, created_at, last_seen_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?4)",
|
||||
params![user_id, token_hash, name, t],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Look up the device a token belongs to, noting that it was just used.
|
||||
pub fn device_by_token(conn: &Connection, token_hash: &str) -> rusqlite::Result<Option<Device>> {
|
||||
let device = conn
|
||||
.query_row(
|
||||
"SELECT id, user_id, name, created_at, last_seen_at FROM devices WHERE token_hash = ?1",
|
||||
[token_hash],
|
||||
|r| {
|
||||
Ok(Device {
|
||||
id: r.get(0)?,
|
||||
user_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
last_seen_at: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(d) = &device {
|
||||
conn.execute("UPDATE devices SET last_seen_at = ?2 WHERE id = ?1", params![d.id, now()])?;
|
||||
}
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub fn devices_for(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Device>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, name, created_at, last_seen_at FROM devices
|
||||
WHERE user_id = ?1 ORDER BY last_seen_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([user_id], |r| {
|
||||
Ok(Device {
|
||||
id: r.get(0)?,
|
||||
user_id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
last_seen_at: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Revoke one of a user's devices. Scoped to the user so nobody can revoke
|
||||
/// someone else's. Returns whether it existed.
|
||||
pub fn delete_device(conn: &Connection, user_id: i64, device_id: i64) -> rusqlite::Result<bool> {
|
||||
Ok(conn.execute(
|
||||
"DELETE FROM devices WHERE id = ?1 AND user_id = ?2",
|
||||
params![device_id, user_id],
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
// --- sign-in in progress ----------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingLogin {
|
||||
pub id: String,
|
||||
pub redirect_uri: String,
|
||||
pub app_state: String,
|
||||
pub code_challenge: String,
|
||||
pub device_name: String,
|
||||
pub oidc_nonce: Option<String>,
|
||||
pub oidc_verifier: Option<String>,
|
||||
}
|
||||
|
||||
pub fn create_pending(conn: &Connection, p: &PendingLogin, ttl_secs: i64) -> rusqlite::Result<()> {
|
||||
// Sweep expired rows here: sign-ins are rare enough that this is the only
|
||||
// housekeeping the table ever needs.
|
||||
let t = now();
|
||||
conn.execute("DELETE FROM pending_logins WHERE expires_at < ?1", [t])?;
|
||||
conn.execute("DELETE FROM auth_codes WHERE expires_at < ?1", [t])?;
|
||||
conn.execute(
|
||||
"INSERT INTO pending_logins
|
||||
(id, redirect_uri, app_state, code_challenge, device_name, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![p.id, p.redirect_uri, p.app_state, p.code_challenge, p.device_name, t + ttl_secs],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pending(conn: &Connection, id: &str) -> rusqlite::Result<Option<PendingLogin>> {
|
||||
conn.query_row(
|
||||
"SELECT id, redirect_uri, app_state, code_challenge, device_name, oidc_nonce, oidc_verifier
|
||||
FROM pending_logins WHERE id = ?1 AND expires_at >= ?2",
|
||||
params![id, now()],
|
||||
|r| {
|
||||
Ok(PendingLogin {
|
||||
id: r.get(0)?,
|
||||
redirect_uri: r.get(1)?,
|
||||
app_state: r.get(2)?,
|
||||
code_challenge: r.get(3)?,
|
||||
device_name: r.get(4)?,
|
||||
oidc_nonce: r.get(5)?,
|
||||
oidc_verifier: r.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn set_pending_oidc(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
nonce: &str,
|
||||
verifier: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE pending_logins SET oidc_nonce = ?2, oidc_verifier = ?3 WHERE id = ?1",
|
||||
params![id, nonce, verifier],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_pending(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM pending_logins WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_auth_code(
|
||||
conn: &Connection,
|
||||
code_hash: &str,
|
||||
user_id: i64,
|
||||
code_challenge: &str,
|
||||
device_name: &str,
|
||||
ttl_secs: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO auth_codes (code_hash, user_id, code_challenge, device_name, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![code_hash, user_id, code_challenge, device_name, now() + ttl_secs],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume an app code: it is deleted whether or not it turns out to be
|
||||
/// redeemable, so a code can be tried exactly once.
|
||||
pub fn take_auth_code(
|
||||
conn: &Connection,
|
||||
code_hash: &str,
|
||||
) -> rusqlite::Result<Option<(i64, String, String)>> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT user_id, code_challenge, device_name FROM auth_codes
|
||||
WHERE code_hash = ?1 AND expires_at >= ?2",
|
||||
params![code_hash, now()],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
conn.execute("DELETE FROM auth_codes WHERE code_hash = ?1", [code_hash])?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
// --- libraries --------------------------------------------------------------
|
||||
|
||||
/// A user's library: (version, document JSON). Version 0 with no document
|
||||
/// means nothing has been stored yet.
|
||||
pub fn get_library(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<(i64, String)>> {
|
||||
conn.query_row(
|
||||
"SELECT version, doc FROM libraries WHERE user_id = ?1",
|
||||
[user_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub enum PutResult {
|
||||
Stored(i64),
|
||||
/// Someone else wrote first: the current version and document.
|
||||
Conflict(i64, Option<String>),
|
||||
}
|
||||
|
||||
/// Replace a user's library, but only if it is still at `if_match`.
|
||||
pub fn put_library(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
if_match: i64,
|
||||
doc: &str,
|
||||
) -> rusqlite::Result<PutResult> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let current = get_library(&tx, user_id)?;
|
||||
let current_version = current.as_ref().map(|(v, _)| *v).unwrap_or(0);
|
||||
if current_version != if_match {
|
||||
return Ok(PutResult::Conflict(current_version, current.map(|(_, d)| d)));
|
||||
}
|
||||
|
||||
let t = now();
|
||||
if let Some((v, old)) = ¤t {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO library_history (user_id, version, doc, saved_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![user_id, v, old, t],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM library_history WHERE user_id = ?1 AND version <= ?2",
|
||||
params![user_id, v - HISTORY_KEEP],
|
||||
)?;
|
||||
}
|
||||
let version = current_version + 1;
|
||||
tx.execute(
|
||||
"INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = excluded.version, doc = excluded.doc, updated_at = excluded.updated_at",
|
||||
params![user_id, version, doc, t],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(PutResult::Stored(version))
|
||||
}
|
||||
|
||||
pub fn history_versions(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<i64>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT version FROM library_history WHERE user_id = ?1 ORDER BY version")?;
|
||||
let rows = stmt.query_map([user_id], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
158
crates/mtgc-server/src/lib.rs
Normal file
158
crates/mtgc-server/src/lib.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! mtgc-server — keeps each user's library so their devices can sync it.
|
||||
//!
|
||||
//! The server is deliberately dumb about libraries: it stores one versioned
|
||||
//! document per user and accepts a write only when the writer names the
|
||||
//! version it is replacing. All merging happens on the devices (see
|
||||
//! `mtgc_core::sync::remote`), which is what lets them keep working offline and
|
||||
//! keeps this side small enough to trust.
|
||||
//!
|
||||
//! Sign-in always happens in the browser, on pages this server serves, and
|
||||
//! ends by handing the app a long-lived token for that one device. Passwords
|
||||
//! therefore never pass through the app, and the app needs no knowledge of
|
||||
//! which sign-in methods a server offers.
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
mod library;
|
||||
mod pages;
|
||||
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use base64::Engine;
|
||||
use rusqlite::Connection;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
/// Largest library document accepted. A large collection serialises to a few
|
||||
/// hundred kilobytes, so this leaves an order of magnitude of headroom while
|
||||
/// still bounding what one request can make the server hold.
|
||||
pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState(Arc<Inner>);
|
||||
|
||||
pub struct Inner {
|
||||
pub config: Config,
|
||||
db: Mutex<Connection>,
|
||||
pub(crate) local: auth::local::LocalAuth,
|
||||
pub(crate) oidc: Option<auth::oidc::Oidc>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: Config, conn: Connection) -> anyhow::Result<Self> {
|
||||
let oidc = config.oidc.clone().map(auth::oidc::Oidc::new).transpose()?;
|
||||
Ok(Self(Arc::new(Inner {
|
||||
config,
|
||||
db: Mutex::new(conn),
|
||||
local: auth::local::LocalAuth::new(),
|
||||
oidc,
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &Config {
|
||||
&self.0.config
|
||||
}
|
||||
|
||||
/// The database. Never hold this across an `.await`.
|
||||
pub fn db(&self) -> MutexGuard<'_, Connection> {
|
||||
// A panic while holding the lock leaves nothing half-done that SQLite's
|
||||
// own transactions don't already roll back, so carry on.
|
||||
self.0.db.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.route("/api/info", get(info))
|
||||
.route("/api/library", get(library::get_library).put(library::put_library))
|
||||
.route("/api/me", get(auth::me))
|
||||
.route("/api/devices/{id}", delete(auth::revoke_device))
|
||||
.route("/auth/start", get(auth::start))
|
||||
.route("/auth/password", post(auth::local::submit))
|
||||
.route("/auth/oidc", get(auth::oidc::begin))
|
||||
.route("/auth/callback", get(auth::oidc::callback))
|
||||
.route("/auth/token", post(auth::token))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Unauthenticated, so the app can check that an address really is an mtgc
|
||||
/// server before sending the user's browser there.
|
||||
async fn info(axum::extract::State(state): axum::extract::State<AppState>) -> Json<serde_json::Value> {
|
||||
let c = state.config();
|
||||
Json(serde_json::json!({
|
||||
"server": "mtgc",
|
||||
"api": 1,
|
||||
"local_auth": c.local_auth,
|
||||
"oidc": c.oidc.as_ref().map(|o| o.display_name.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// An error answered as `{"error": "..."}` with a status code.
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError(pub StatusCode, pub String);
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
|
||||
Self(status, msg.into())
|
||||
}
|
||||
|
||||
pub fn unauthorized() -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, "not signed in")
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for ApiError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
tracing::error!("database: {e}");
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn random_bytes<const N: usize>() -> [u8; N] {
|
||||
let mut buf = [0u8; N];
|
||||
getrandom::fill(&mut buf).expect("the OS random number generator failed");
|
||||
buf
|
||||
}
|
||||
|
||||
pub(crate) fn random_hex(bytes: usize) -> String {
|
||||
let buf: [u8; 32] = random_bytes();
|
||||
hex(&buf[..bytes.min(32)])
|
||||
}
|
||||
|
||||
/// A 256-bit secret, URL-safe.
|
||||
pub(crate) fn random_token() -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random_bytes::<32>())
|
||||
}
|
||||
|
||||
/// Tokens and codes are stored only as hashes, so a leaked database can't be
|
||||
/// used to impersonate a device. SHA-256 rather than a password hash because
|
||||
/// they are already 256 random bits — there is nothing to brute-force.
|
||||
pub(crate) fn sha256_hex(s: &str) -> String {
|
||||
hex(&Sha256::digest(s.as_bytes()))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
77
crates/mtgc-server/src/library.rs
Normal file
77
crates/mtgc-server/src/library.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! The one resource: the signed-in user's library document.
|
||||
//!
|
||||
//! `GET` returns `{version, doc}`. `PUT` replaces the document only when its
|
||||
//! `If-Match` names the current version, answering `412` with the current
|
||||
//! `{version, doc}` otherwise, so the device can merge against it at once.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mtgc_core::sync::LibraryDoc;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::Authed;
|
||||
use crate::db::{self, PutResult};
|
||||
use crate::{ApiError, AppState};
|
||||
|
||||
pub async fn get_library(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
let stored = db::get_library(&state.db(), auth.user.id)?;
|
||||
Ok(Json(wire(stored)?).into_response())
|
||||
}
|
||||
|
||||
pub async fn put_library(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Response, ApiError> {
|
||||
let if_match = if_match(&headers)?;
|
||||
|
||||
// Validate with the app's own parser, so the server never stores anything
|
||||
// a device would refuse to read back — including a format newer than
|
||||
// this server understands.
|
||||
let doc = LibraryDoc::from_json(&body)
|
||||
.map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("not a library: {e}")))?;
|
||||
// Store it normalised rather than as sent: same content, same bytes.
|
||||
let text = serde_json::to_string(&doc).map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
|
||||
let result = db::put_library(&state.db(), auth.user.id, if_match, &text)?;
|
||||
Ok(match result {
|
||||
PutResult::Stored(version) => Json(json!({ "version": version })).into_response(),
|
||||
PutResult::Conflict(version, doc) => {
|
||||
(StatusCode::PRECONDITION_FAILED, Json(wire(doc.map(|d| (version, d)))?)).into_response()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `{version, doc}`, with an empty library at version 0 for a user who has
|
||||
/// never stored one.
|
||||
fn wire(stored: Option<(i64, String)>) -> Result<Value, ApiError> {
|
||||
Ok(match stored {
|
||||
Some((version, text)) => {
|
||||
let doc: Value = serde_json::from_str(&text).map_err(|e| {
|
||||
tracing::error!("stored library is unreadable: {e}");
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "stored library is unreadable")
|
||||
})?;
|
||||
json!({ "version": version, "doc": doc })
|
||||
}
|
||||
None => json!({ "version": 0, "doc": LibraryDoc::default() }),
|
||||
})
|
||||
}
|
||||
|
||||
/// The version a write replaces. Required: a write without one could only be
|
||||
/// a blind overwrite, which is exactly what this API exists to prevent.
|
||||
fn if_match(headers: &HeaderMap) -> Result<i64, ApiError> {
|
||||
let raw = headers
|
||||
.get("if-match")
|
||||
.ok_or_else(|| {
|
||||
ApiError::new(StatusCode::PRECONDITION_REQUIRED, "If-Match with the version being replaced is required")
|
||||
})?
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.trim();
|
||||
let raw = raw.strip_prefix("W/").unwrap_or(raw).trim_matches('"');
|
||||
raw.parse()
|
||||
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "If-Match must be a version number"))
|
||||
}
|
||||
170
crates/mtgc-server/src/main.rs
Normal file
170
crates/mtgc-server/src/main.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! `mtgc-server` — run the sync server, or manage its local accounts.
|
||||
//!
|
||||
//! ```text
|
||||
//! mtgc-server serve (the default)
|
||||
//! mtgc-server user add <name> [--display-name <text>]
|
||||
//! mtgc-server user passwd <name>
|
||||
//! mtgc-server user remove <name> --yes
|
||||
//! mtgc-server user list
|
||||
//! ```
|
||||
//!
|
||||
//! The `user` commands read the same `MTGC_DB` as the server, so in a container
|
||||
//! they run with `docker compose exec mtgc-server mtgc-server user add me`.
|
||||
|
||||
use std::io::{BufRead, IsTerminal};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use mtgc_server::auth::local::{hash_password, normalize_username};
|
||||
use mtgc_server::{db, router, AppState, Config};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
mtgc-server run the server
|
||||
mtgc-server user add <name> [--display-name <text>]
|
||||
mtgc-server user passwd <name>
|
||||
mtgc-server user remove <name> --yes
|
||||
mtgc-server user list";
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("serve") => serve(),
|
||||
Some("user") => user(&args[1..]),
|
||||
Some("-h" | "--help" | "help") => {
|
||||
println!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => bail!("unknown command {other:?}\n{USAGE}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "mtgc_server=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
let conn = db::open(&config.db_path)
|
||||
.with_context(|| format!("opening {}", config.db_path.display()))?;
|
||||
let bind = config.bind;
|
||||
tracing::info!(
|
||||
public_url = %config.public_url,
|
||||
local_auth = config.local_auth,
|
||||
oidc = config.oidc.as_ref().map(|o| o.issuer.as_str()).unwrap_or("off"),
|
||||
db = %config.db_path.display(),
|
||||
"starting"
|
||||
);
|
||||
let state = AppState::new(config, conn)?;
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(async move {
|
||||
let listener = tokio::net::TcpListener::bind(bind)
|
||||
.await
|
||||
.with_context(|| format!("binding {bind}"))?;
|
||||
tracing::info!("listening on {bind}");
|
||||
axum::serve(listener, router(state))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Account management needs only the database, not the rest of the server's
|
||||
/// configuration, so it works before OIDC (say) is set up.
|
||||
fn open_db() -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = std::env::var("MTGC_DB").unwrap_or_else(|_| "mtgc-server.db".into());
|
||||
db::open(std::path::Path::new(&path)).with_context(|| format!("opening {path}"))
|
||||
}
|
||||
|
||||
fn user(args: &[String]) -> anyhow::Result<()> {
|
||||
let name = |i: usize| -> anyhow::Result<String> {
|
||||
let n = normalize_username(args.get(i).map(String::as_str).unwrap_or_default());
|
||||
if n.is_empty() {
|
||||
bail!("a username is required\n{USAGE}");
|
||||
}
|
||||
if n.len() > 64 || n.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
bail!("usernames are up to 64 characters, without spaces");
|
||||
}
|
||||
Ok(n)
|
||||
};
|
||||
|
||||
match args.first().map(String::as_str) {
|
||||
Some("add") => {
|
||||
let username = name(1)?;
|
||||
let display_name = flag(args, "--display-name").unwrap_or_else(|| username.clone());
|
||||
let conn = open_db()?;
|
||||
if db::local_user(&conn, &username)?.is_some() {
|
||||
bail!("{username} already exists — use `user passwd` to change the password");
|
||||
}
|
||||
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
|
||||
db::create_local_user(&conn, &username, &display_name, &hash)?;
|
||||
println!("Created {username}.");
|
||||
}
|
||||
Some("passwd") => {
|
||||
let username = name(1)?;
|
||||
let conn = open_db()?;
|
||||
if db::local_user(&conn, &username)?.is_none() {
|
||||
bail!("no local account named {username}");
|
||||
}
|
||||
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
|
||||
db::set_local_password(&conn, &username, &hash)?;
|
||||
println!("Password changed for {username}. Devices already signed in stay signed in.");
|
||||
}
|
||||
Some("remove") => {
|
||||
let username = name(1)?;
|
||||
if !args.iter().any(|a| a == "--yes") {
|
||||
bail!(
|
||||
"this deletes {username}'s account, signs out their devices, and deletes \
|
||||
the library stored on this server (devices keep their own copies). \
|
||||
Re-run with --yes to confirm."
|
||||
);
|
||||
}
|
||||
if db::delete_local_user(&open_db()?, &username)? {
|
||||
println!("Removed {username}.");
|
||||
} else {
|
||||
bail!("no local account named {username}");
|
||||
}
|
||||
}
|
||||
Some("list") => {
|
||||
let users = db::list_users(&open_db()?)?;
|
||||
if users.is_empty() {
|
||||
println!("No accounts yet. Add one with `mtgc-server user add <name>`.");
|
||||
}
|
||||
for (u, devices) in users {
|
||||
let kind = if u.iss == db::LOCAL_ISS { "local".to_string() } else { format!("oidc {}", u.iss) };
|
||||
let who = if u.email.is_empty() { u.display_name } else { format!("{} <{}>", u.display_name, u.email) };
|
||||
println!("{:<24} {:<8} devices: {:<3} {}", u.sub, kind, devices, who);
|
||||
}
|
||||
}
|
||||
_ => bail!("{USAGE}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flag(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
|
||||
}
|
||||
|
||||
/// Prompt twice without echo at a terminal; otherwise read one line from
|
||||
/// stdin, so it can be scripted (`echo "$PW" | mtgc-server user add me`).
|
||||
fn read_new_password() -> anyhow::Result<String> {
|
||||
let password = if std::io::stdin().is_terminal() {
|
||||
let first = rpassword::prompt_password("New password: ")?;
|
||||
let again = rpassword::prompt_password("Again: ")?;
|
||||
if first != again {
|
||||
bail!("the passwords didn't match");
|
||||
}
|
||||
first
|
||||
} else {
|
||||
let mut line = String::new();
|
||||
std::io::stdin().lock().read_line(&mut line)?;
|
||||
line.trim_end_matches(['\r', '\n']).to_string()
|
||||
};
|
||||
if password.chars().count() < 8 {
|
||||
bail!("use at least 8 characters");
|
||||
}
|
||||
Ok(password)
|
||||
}
|
||||
158
crates/mtgc-server/src/pages.rs
Normal file
158
crates/mtgc-server/src/pages.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! The few HTML pages the server shows during sign-in.
|
||||
//!
|
||||
//! Hand-written and script-free: three small pages don't justify a template
|
||||
//! engine, and with no JavaScript the content security policy can forbid it
|
||||
//! outright.
|
||||
|
||||
use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
|
||||
use crate::db::PendingLogin;
|
||||
|
||||
/// The sign-in page: a username/password form, plus a button for the
|
||||
/// identity provider when one is configured. `username` refills the form after
|
||||
/// a failed attempt, so only the password needs retyping.
|
||||
pub fn login(
|
||||
pending: &PendingLogin,
|
||||
oidc_name: Option<&str>,
|
||||
error: Option<&str>,
|
||||
username: &str,
|
||||
) -> Response {
|
||||
let error = error
|
||||
.map(|e| format!(r#"<p class="error" role="alert">{}</p>"#, esc(e)))
|
||||
.unwrap_or_default();
|
||||
let oidc = oidc_name
|
||||
.map(|name| {
|
||||
format!(
|
||||
r#"<a class="button secondary" href="/auth/oidc?pending={id}">Sign in with {name}</a>
|
||||
<div class="or"><span>or</span></div>"#,
|
||||
id = esc(&pending.id),
|
||||
name = esc(name),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body = format!(
|
||||
r#"<h1>Sign in to mtgc</h1>
|
||||
<p class="muted">Signing in <strong>{device}</strong> to sync your library.</p>
|
||||
{error}
|
||||
{oidc}
|
||||
<form method="post" action="/auth/password">
|
||||
<input type="hidden" name="pending" value="{id}">
|
||||
<label>Username <input name="username" value="{username}" autocomplete="username" autocapitalize="none" required{focus_user}></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password" required{focus_password}></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>"#,
|
||||
device = esc(&pending.device_name),
|
||||
id = esc(&pending.id),
|
||||
username = esc(username),
|
||||
focus_user = if username.is_empty() { " autofocus" } else { "" },
|
||||
focus_password = if username.is_empty() { "" } else { " autofocus" },
|
||||
);
|
||||
page(StatusCode::OK, "Sign in", &body, "")
|
||||
}
|
||||
|
||||
/// Sign-in finished: send the browser back to the app.
|
||||
///
|
||||
/// A page rather than a bare redirect, because a phone browser may decline to
|
||||
/// follow a redirect into an app on its own; the button then gives the user a
|
||||
/// tap that it will honour.
|
||||
pub fn return_to_app(url: &str) -> Response {
|
||||
let url = esc(url);
|
||||
let body = format!(
|
||||
r#"<h1>Returning to mtgc…</h1>
|
||||
<p class="muted">If the app doesn't open by itself, use the button below. You can close this tab afterwards.</p>
|
||||
<a class="button" href="{url}">Return to mtgc</a>"#
|
||||
);
|
||||
// Meta refresh rather than a 30x, so the page above is what's left showing
|
||||
// if the hand-off doesn't happen.
|
||||
let refresh = format!(r#"<meta http-equiv="refresh" content="0;url={url}">"#);
|
||||
page(StatusCode::OK, "Signed in", &body, &refresh)
|
||||
}
|
||||
|
||||
pub fn error(status: StatusCode, message: &str) -> Response {
|
||||
let body = format!(
|
||||
r#"<h1>Sign-in didn't work</h1>
|
||||
<p>{}</p>"#,
|
||||
esc(message)
|
||||
);
|
||||
page(status, "Sign-in problem", &body, "")
|
||||
}
|
||||
|
||||
fn page(status: StatusCode, title: &str, body: &str, head_extra: &str) -> Response {
|
||||
let mut resp = (status, Html(render(title, body, head_extra))).into_response();
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"),
|
||||
);
|
||||
// The password page must never be framed by another site.
|
||||
h.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
// Each page embeds a single-use id or code.
|
||||
h.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
|
||||
fn render(title: &str, body: &str, head_extra: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{head_extra}
|
||||
<title>{title} · mtgc</title>
|
||||
<style>{STYLE}</style>
|
||||
</head>
|
||||
<body><main>{body}</main></body>
|
||||
</html>"#,
|
||||
title = esc(title),
|
||||
)
|
||||
}
|
||||
|
||||
/// Matches the app's terracotta brand colour, light and dark.
|
||||
const STYLE: &str = r#"
|
||||
:root { --bg:#faf7f5; --card:#fff; --fg:#2b2320; --muted:#6f625c; --accent:#c8553d; --accent-fg:#fff; --line:#e6ddd8; --err:#a12d1c; color-scheme: light dark; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg:#1c1816; --card:#262120; --fg:#f1e9e5; --muted:#b3a59e; --line:#3d3532; --err:#ff8f7a; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; padding:16px; }
|
||||
main { width:100%; max-width:380px; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:28px 24px; }
|
||||
h1 { font-size:1.35rem; margin:0 0 8px; }
|
||||
.muted { color:var(--muted); margin:0 0 20px; }
|
||||
.error { color:var(--err); margin:0 0 16px; }
|
||||
form { display:flex; flex-direction:column; gap:14px; }
|
||||
label { display:flex; flex-direction:column; gap:6px; font-size:.9rem; color:var(--muted); }
|
||||
input { font:inherit; color:var(--fg); background:var(--bg); padding:10px 12px; border:1px solid var(--line); border-radius:8px; }
|
||||
input:focus { outline:2px solid var(--accent); outline-offset:1px; }
|
||||
button, .button { display:block; width:100%; text-align:center; font:inherit; font-weight:600; padding:11px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:var(--accent-fg); cursor:pointer; text-decoration:none; }
|
||||
.button.secondary { background:transparent; color:var(--accent); }
|
||||
.or { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:.85rem; margin:18px 0; }
|
||||
.or::before, .or::after { content:""; flex:1; border-top:1px solid var(--line); }
|
||||
"#;
|
||||
|
||||
/// Escape text for HTML content and double-quoted attributes.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escaping_neutralises_markup_and_attribute_breakouts() {
|
||||
assert_eq!(esc(r#"<b>"x" & 'y'</b>"#), "<b>"x" & 'y'</b>");
|
||||
}
|
||||
}
|
||||
475
crates/mtgc-server/tests/api.rs
Normal file
475
crates/mtgc-server/tests/api.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
//! The server driven through its router in-process: sign-in end to end, the
|
||||
//! library's compare-and-swap, and device revocation.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Request, Response, StatusCode};
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use mtgc_server::auth::local::hash_password;
|
||||
use mtgc_server::auth::pkce_challenge;
|
||||
use mtgc_server::{db, router, AppState, Config};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
const VERIFIER: &str = "a-perfectly-good-pkce-verifier-that-is-long-enough-0123456789";
|
||||
const REDIRECT: &str = "http://127.0.0.1:43210/callback";
|
||||
|
||||
struct Server {
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn new(vars: &[(&str, &str)]) -> Self {
|
||||
let mut all = vec![("MTGC_PUBLIC_URL", "https://mtgc.example")];
|
||||
all.extend_from_slice(vars);
|
||||
let config = Config::from_lookup(|k| {
|
||||
all.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string())
|
||||
})
|
||||
.unwrap();
|
||||
let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap();
|
||||
Self { state }
|
||||
}
|
||||
|
||||
fn local() -> Self {
|
||||
let s = Self::new(&[]);
|
||||
s.add_user("alice", "correct horse battery");
|
||||
s
|
||||
}
|
||||
|
||||
fn add_user(&self, name: &str, password: &str) {
|
||||
let hash = hash_password(password).unwrap();
|
||||
db::create_local_user(&self.state.db(), name, name, &hash).unwrap();
|
||||
}
|
||||
|
||||
fn app(&self) -> Router {
|
||||
router(self.state.clone())
|
||||
}
|
||||
|
||||
async fn send(&self, req: Request<Body>) -> Response<Body> {
|
||||
self.app().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
/// Open the sign-in page as the app would. Returns the pending id (from
|
||||
/// the cookie that binds it to this "browser") and the page.
|
||||
async fn start(&self) -> (String, Response<Body>) {
|
||||
let uri = format!(
|
||||
"/auth/start?redirect_uri={}&code_challenge={}&code_challenge_method=S256&state=app-state&device_name=Test%20phone",
|
||||
urlencode(REDIRECT),
|
||||
pkce_challenge(VERIFIER),
|
||||
);
|
||||
let resp = self.send(get(&uri)).await;
|
||||
let cookie = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("the sign-in should be bound to the browser")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let pending = cookie
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.strip_prefix("mtgc_login=")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
(pending, resp)
|
||||
}
|
||||
|
||||
async fn submit_password(&self, pending: &str, cookie: Option<&str>, user: &str, pw: &str) -> String {
|
||||
let body = format!(
|
||||
"pending={}&username={}&password={}",
|
||||
urlencode(pending),
|
||||
urlencode(user),
|
||||
urlencode(pw)
|
||||
);
|
||||
let mut req = Request::post("/auth/password")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
|
||||
if let Some(c) = cookie {
|
||||
req = req.header(header::COOKIE, format!("mtgc_login={c}"));
|
||||
}
|
||||
text(self.send(req.body(Body::from(body)).unwrap()).await).await
|
||||
}
|
||||
|
||||
/// Sign in with a password and return the code the app would receive.
|
||||
async fn code_for(&self, user: &str, pw: &str) -> String {
|
||||
let (pending, _) = self.start().await;
|
||||
let page = self.submit_password(&pending, Some(&pending), user, pw).await;
|
||||
code_from(&page).unwrap_or_else(|| panic!("no code in:\n{page}"))
|
||||
}
|
||||
|
||||
async fn redeem(&self, code: &str, verifier: &str) -> Response<Body> {
|
||||
self.send(post_json("/auth/token", json!({ "code": code, "code_verifier": verifier }), None))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn sign_in(&self, user: &str, pw: &str) -> String {
|
||||
let code = self.code_for(user, pw).await;
|
||||
let resp = self.redeem(&code, VERIFIER).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
body_json(resp).await["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn get_library(&self, token: &str) -> Response<Body> {
|
||||
self.send(
|
||||
Request::get("/api/library")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
|
||||
let mut req = Request::put("/api/library")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(v) = if_match {
|
||||
req = req.header(header::IF_MATCH, v);
|
||||
}
|
||||
self.send(req.body(Body::from(doc.to_string())).unwrap()).await
|
||||
}
|
||||
}
|
||||
|
||||
fn get(uri: &str) -> Request<Body> {
|
||||
Request::get(uri).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
fn post_json(uri: &str, body: Value, token: Option<&str>) -> Request<Body> {
|
||||
let mut req = Request::post(uri).header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(t) = token {
|
||||
req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
|
||||
}
|
||||
req.body(Body::from(body.to_string())).unwrap()
|
||||
}
|
||||
|
||||
async fn text(resp: Response<Body>) -> String {
|
||||
String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap()
|
||||
}
|
||||
|
||||
async fn body_json(resp: Response<Body>) -> Value {
|
||||
serde_json::from_str(&text(resp).await).unwrap()
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
|
||||
/// The code from the "returning to the app" page's link.
|
||||
fn code_from(page: &str) -> Option<String> {
|
||||
let start = page.find(r#"href=""#)? + 6;
|
||||
let href = &page[start..start + page[start..].find('"')?];
|
||||
let href = href.replace("&", "&");
|
||||
let u = url::Url::parse(&href).ok()?;
|
||||
assert!(href.starts_with(REDIRECT), "code must go to the app: {href}");
|
||||
assert_eq!(
|
||||
u.query_pairs().find(|(k, _)| k == "state").map(|(_, v)| v.into_owned()).as_deref(),
|
||||
Some("app-state"),
|
||||
"the app's state must come back with the code"
|
||||
);
|
||||
u.query_pairs().find(|(k, _)| k == "code").map(|(_, v)| v.into_owned())
|
||||
}
|
||||
|
||||
fn library(cards: &[(&str, i64)]) -> Value {
|
||||
let cards: serde_json::Map<String, Value> =
|
||||
cards.iter().map(|(id, q)| (id.to_string(), json!({ "quantity": q }))).collect();
|
||||
json!({ "version": 1, "cards": cards, "decks": {} })
|
||||
}
|
||||
|
||||
// --- sign-in ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_sign_in_yields_a_working_device_token() {
|
||||
let s = Server::local();
|
||||
let token = s.sign_in("alice", "correct horse battery").await;
|
||||
|
||||
let resp = s.get_library(&token).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["version"], 0, "a new account starts empty");
|
||||
assert_eq!(body["doc"]["cards"], json!({}));
|
||||
|
||||
let me = s
|
||||
.send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {token}")).body(Body::empty()).unwrap())
|
||||
.await;
|
||||
let me = body_json(me).await;
|
||||
assert_eq!(me["user"]["username"], "alice");
|
||||
assert_eq!(me["user"]["method"], "local");
|
||||
assert_eq!(me["devices"][0]["name"], "Test phone");
|
||||
assert_eq!(me["devices"][0]["current"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usernames_are_not_case_sensitive() {
|
||||
let s = Server::local();
|
||||
s.sign_in(" Alice ", "correct horse battery").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_and_an_unknown_user_look_the_same() {
|
||||
let s = Server::local();
|
||||
let (pending, _) = s.start().await;
|
||||
|
||||
let wrong = s.submit_password(&pending, Some(&pending), "alice", "nope").await;
|
||||
let unknown = s.submit_password(&pending, Some(&pending), "mallory", "nope").await;
|
||||
|
||||
for page in [&wrong, &unknown] {
|
||||
assert!(page.contains("Invalid username or password."));
|
||||
assert!(code_from(page).is_none());
|
||||
}
|
||||
assert!(wrong.contains(r#"value="alice""#), "the username is kept for the retry");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_failures_are_slowed_down() {
|
||||
let s = Server::local();
|
||||
let (pending, _) = s.start().await;
|
||||
for _ in 0..5 {
|
||||
s.submit_password(&pending, Some(&pending), "alice", "nope").await;
|
||||
}
|
||||
// Even the right password has to wait now.
|
||||
let page = s.submit_password(&pending, Some(&pending), "alice", "correct horse battery").await;
|
||||
assert!(page.contains("Too many attempts"), "{page}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_post_from_another_browser_is_refused() {
|
||||
let s = Server::local();
|
||||
let (pending, _) = s.start().await;
|
||||
|
||||
let no_cookie = s.submit_password(&pending, None, "alice", "correct horse battery").await;
|
||||
let other = s.submit_password(&pending, Some("someone-elses"), "alice", "correct horse battery").await;
|
||||
|
||||
for page in [&no_cookie, &other] {
|
||||
assert!(page.contains("expired"), "{page}");
|
||||
assert!(code_from(page).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codes_need_the_right_verifier_and_work_once() {
|
||||
let s = Server::local();
|
||||
|
||||
let code = s.code_for("alice", "correct horse battery").await;
|
||||
assert_eq!(s.redeem(&code, "some-other-verifier-of-a-reasonable-length-0000000").await.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
s.redeem(&code, VERIFIER).await.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"a failed attempt burns the code"
|
||||
);
|
||||
|
||||
let code = s.code_for("alice", "correct horse battery").await;
|
||||
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::OK);
|
||||
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST, "single use");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_code_is_refused() {
|
||||
let s = Server::local();
|
||||
let code = s.code_for("alice", "correct horse battery").await;
|
||||
s.state.db().execute("UPDATE auth_codes SET expires_at = 0", []).unwrap();
|
||||
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codes_are_only_ever_sent_to_the_app() {
|
||||
let s = Server::local();
|
||||
for bad in [
|
||||
"https://evil.example/callback",
|
||||
"http://127.0.0.1.evil.example:80/callback",
|
||||
"http://localhost:1234/callback",
|
||||
"http://127.0.0.1:1234/elsewhere",
|
||||
"mtgc://elsewhere",
|
||||
] {
|
||||
let uri = format!(
|
||||
"/auth/start?redirect_uri={}&code_challenge={}&state=x",
|
||||
urlencode(bad),
|
||||
pkce_challenge(VERIFIER)
|
||||
);
|
||||
assert_eq!(s.send(get(&uri)).await.status(), StatusCode::BAD_REQUEST, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_phone_apps_scheme_is_accepted() {
|
||||
let s = Server::local();
|
||||
let uri = format!(
|
||||
"/auth/start?redirect_uri={}&code_challenge={}&state=x",
|
||||
urlencode("mtgc://auth/callback"),
|
||||
pkce_challenge(VERIFIER)
|
||||
);
|
||||
assert_eq!(s.send(get(&uri)).await.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_only_oidc_the_server_skips_straight_to_the_provider() {
|
||||
let s = Server::new(&[
|
||||
("MTGC_LOCAL_AUTH", "false"),
|
||||
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
|
||||
("MTGC_OIDC_CLIENT_ID", "mtgc"),
|
||||
("MTGC_OIDC_CLIENT_SECRET", "secret"),
|
||||
]);
|
||||
let (pending, resp) = s.start().await;
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
resp.headers()[header::LOCATION].to_str().unwrap(),
|
||||
format!("/auth/oidc?pending={pending}")
|
||||
);
|
||||
|
||||
// And the password form is gone, not just hidden.
|
||||
let page = s.submit_password(&pending, Some(&pending), "alice", "whatever").await;
|
||||
assert!(page.contains("turned off"), "{page}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_both_methods_the_page_offers_both() {
|
||||
let s = Server::new(&[
|
||||
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
|
||||
("MTGC_OIDC_CLIENT_ID", "mtgc"),
|
||||
("MTGC_OIDC_CLIENT_SECRET", "secret"),
|
||||
("MTGC_OIDC_NAME", "Authentik"),
|
||||
]);
|
||||
let (_, resp) = s.start().await;
|
||||
let page = text(resp).await;
|
||||
assert!(page.contains("Sign in with Authentik"));
|
||||
assert!(page.contains(r#"action="/auth/password""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_describes_itself() {
|
||||
let s = Server::local();
|
||||
let info = body_json(s.send(get("/api/info")).await).await;
|
||||
assert_eq!(info["server"], "mtgc");
|
||||
assert_eq!(info["local_auth"], true);
|
||||
assert_eq!(info["oidc"], Value::Null);
|
||||
}
|
||||
|
||||
// --- devices ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_library_needs_a_valid_token() {
|
||||
let s = Server::local();
|
||||
assert_eq!(s.send(get("/api/library")).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(s.get_library("mtgc_made-up").await.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signing_out_revokes_only_that_device() {
|
||||
let s = Server::local();
|
||||
let phone = s.sign_in("alice", "correct horse battery").await;
|
||||
let desktop = s.sign_in("alice", "correct horse battery").await;
|
||||
|
||||
let resp = s.send(post_json("/auth/logout", json!({}), Some(&phone))).await;
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
assert_eq!(s.get_library(&phone).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(s.get_library(&desktop).await.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lost_device_can_be_revoked_from_another_but_not_by_someone_else() {
|
||||
let s = Server::local();
|
||||
s.add_user("bob", "another fine password");
|
||||
let lost = s.sign_in("alice", "correct horse battery").await;
|
||||
let mine = s.sign_in("alice", "correct horse battery").await;
|
||||
let bobs = s.sign_in("bob", "another fine password").await;
|
||||
|
||||
let me = s
|
||||
.send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {mine}")).body(Body::empty()).unwrap())
|
||||
.await;
|
||||
let devices = body_json(me).await["devices"].clone();
|
||||
let lost_id = devices
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["current"] == false)
|
||||
.unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
let revoke = |token: String| {
|
||||
Request::delete(format!("/api/devices/{lost_id}"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(s.send(revoke(bobs)).await.status(), StatusCode::NOT_FOUND, "not bob's to revoke");
|
||||
assert_eq!(s.get_library(&lost).await.status(), StatusCode::OK);
|
||||
|
||||
assert_eq!(s.send(revoke(mine)).await.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(s.get_library(&lost).await.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// --- the library ----------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_must_name_the_version_they_replace() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
|
||||
let resp = s.put_library(&t, Some("\"0\""), library(&[("a", 1)])).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["version"], 1);
|
||||
|
||||
// A second device still thinks it's at 0: refused, and told what is there.
|
||||
let resp = s.put_library(&t, Some("0"), library(&[("b", 2)])).await;
|
||||
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
|
||||
let current = body_json(resp).await;
|
||||
assert_eq!(current["version"], 1);
|
||||
assert_eq!(current["doc"]["cards"]["a"]["quantity"], 1);
|
||||
|
||||
let resp = s.put_library(&t, None, library(&[("b", 2)])).await;
|
||||
assert_eq!(resp.status(), StatusCode::PRECONDITION_REQUIRED, "no blind overwrites");
|
||||
|
||||
let got = body_json(s.get_library(&t).await).await;
|
||||
assert_eq!(got["version"], 1);
|
||||
assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_to_a_library_that_does_not_exist_yet_must_say_version_zero() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
let resp = s.put_library(&t, Some("3"), library(&[("a", 1)])).await;
|
||||
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
|
||||
assert_eq!(body_json(resp).await["version"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_valid_libraries_are_stored() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
|
||||
let newer = json!({ "version": 99, "cards": {}, "decks": {} });
|
||||
assert_eq!(s.put_library(&t, Some("0"), newer).await.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_user_has_their_own_library() {
|
||||
let s = Server::local();
|
||||
s.add_user("bob", "another fine password");
|
||||
let alice = s.sign_in("alice", "correct horse battery").await;
|
||||
let bob = s.sign_in("bob", "another fine password").await;
|
||||
|
||||
s.put_library(&alice, Some("0"), library(&[("a", 1)])).await;
|
||||
|
||||
let bobs = body_json(s.get_library(&bob).await).await;
|
||||
assert_eq!(bobs["version"], 0);
|
||||
assert_eq!(bobs["doc"]["cards"], json!({}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_versions_are_kept_up_to_a_limit() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
let total = db::HISTORY_KEEP + 10;
|
||||
for v in 0..total {
|
||||
let resp = s.put_library(&t, Some(&v.to_string()), library(&[("a", v)])).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let user_id: i64 = s.state.db().query_row("SELECT id FROM users WHERE sub = 'alice'", [], |r| r.get(0)).unwrap();
|
||||
let kept = db::history_versions(&s.state.db(), user_id).unwrap();
|
||||
assert_eq!(kept.len() as i64, db::HISTORY_KEEP);
|
||||
assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept");
|
||||
}
|
||||
57
crates/mtgc-server/tests/cli.rs
Normal file
57
crates/mtgc-server/tests/cli.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! The `user` subcommands, run as the real binary against a scratch database.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
fn run(db: &std::path::Path, args: &[&str], stdin: &str) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_mtgc-server"))
|
||||
.args(args)
|
||||
.env("MTGC_DB", db)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
fn out(o: &Output) -> String {
|
||||
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_accounts_can_be_added_changed_listed_and_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("mtgc-server-cli-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = dir.join("server.db");
|
||||
|
||||
let o = run(&db, &["user", "add", "Alice", "--display-name", "Alice A."], "a long password\n");
|
||||
assert!(o.status.success(), "{}", out(&o));
|
||||
|
||||
let o = run(&db, &["user", "add", "alice"], "a long password\n");
|
||||
assert!(!o.status.success(), "duplicate names must be refused");
|
||||
|
||||
let o = run(&db, &["user", "add", "bob"], "short\n");
|
||||
assert!(!o.status.success(), "short passwords must be refused");
|
||||
|
||||
let o = run(&db, &["user", "passwd", "alice"], "another long password\n");
|
||||
assert!(o.status.success(), "{}", out(&o));
|
||||
let conn = mtgc_server::db::open(&db).unwrap();
|
||||
let (_, hash) = mtgc_server::db::local_user(&conn, "alice").unwrap().unwrap();
|
||||
assert!(hash.starts_with("$argon2id$"));
|
||||
drop(conn);
|
||||
|
||||
let o = run(&db, &["user", "list"], "");
|
||||
assert!(out(&o).contains("alice") && out(&o).contains("Alice A."), "{}", out(&o));
|
||||
|
||||
let o = run(&db, &["user", "remove", "alice"], "");
|
||||
assert!(!o.status.success(), "removal must be confirmed");
|
||||
let o = run(&db, &["user", "remove", "alice", "--yes"], "");
|
||||
assert!(o.status.success(), "{}", out(&o));
|
||||
let o = run(&db, &["user", "list"], "");
|
||||
assert!(!out(&o).contains("alice"), "{}", out(&o));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
161
crates/mtgc-server/tests/sync_e2e.rs
Normal file
161
crates/mtgc-server/tests/sync_e2e.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! The app's sync client against the real server over HTTP: the wire protocol
|
||||
//! end to end, with two devices that each keep a SQLite working copy.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce};
|
||||
use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED};
|
||||
use mtgc_core::sync::{apply_doc, merge_into, read_doc, CardEntry};
|
||||
use mtgc_server::auth::local::hash_password;
|
||||
use mtgc_server::{db, router, AppState, Config};
|
||||
|
||||
/// Start a server on a free port and return its URL. It runs on its own thread
|
||||
/// for the rest of the test process.
|
||||
fn spawn_server() -> (String, AppState) {
|
||||
let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", std_listener.local_addr().unwrap());
|
||||
let public = url.clone();
|
||||
let config = Config::from_lookup(move |k| (k == "MTGC_PUBLIC_URL").then(|| public.clone())).unwrap();
|
||||
let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap();
|
||||
let app = router(state.clone());
|
||||
std_listener.set_nonblocking(true).unwrap();
|
||||
std::thread::spawn(move || {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async move {
|
||||
let listener = tokio::net::TcpListener::from_std(std_listener).unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
});
|
||||
(url, state)
|
||||
}
|
||||
|
||||
/// Sign in the way the app does, playing the browser's part by hand.
|
||||
fn sign_in(server: &str, user: &str, password: &str) -> mtgc_core::sync::account::SignedIn {
|
||||
let pkce = Pkce::new();
|
||||
let start = start_url(server, "http://127.0.0.1:9/callback", &pkce, "st", "Test device");
|
||||
|
||||
let page = ureq::get(&start).call().unwrap();
|
||||
let cookie = page.header("set-cookie").unwrap().split(';').next().unwrap().to_string();
|
||||
let pending = cookie.strip_prefix("mtgc_login=").unwrap().to_string();
|
||||
|
||||
let done = ureq::post(&format!("{server}/auth/password"))
|
||||
.set("Cookie", &cookie)
|
||||
.send_form(&[("pending", &pending), ("username", user), ("password", password)])
|
||||
.unwrap()
|
||||
.into_string()
|
||||
.unwrap();
|
||||
let start = done.find("code=").expect("signed in") + 5;
|
||||
let code = &done[start..start + done[start..].find('&').unwrap()];
|
||||
|
||||
redeem_code(server, code, &pkce.verifier).unwrap()
|
||||
}
|
||||
|
||||
struct Device {
|
||||
db: PathBuf,
|
||||
base: SyncBase,
|
||||
remote: HttpRemote,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
fn new(dir: &Path, name: &str, server: &str, token: &str) -> Self {
|
||||
Self {
|
||||
db: dir.join(format!("{name}.db")),
|
||||
base: SyncBase::default(),
|
||||
remote: HttpRemote::new(server, token),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(&mut self) -> mtgc_core::Result<mtgc_core::sync::remote::CycleReport> {
|
||||
let path = self.db.clone();
|
||||
sync_cycle(&self.remote, &mut self.base, |base, theirs| {
|
||||
let conn = mtgc_core::db::open_library(&path)?;
|
||||
merge_into(&conn, base, theirs)
|
||||
})
|
||||
}
|
||||
|
||||
fn set(&self, id: &str, qty: i64) {
|
||||
let conn = mtgc_core::db::open_library(&self.db).unwrap();
|
||||
let mut doc = read_doc(&conn).unwrap();
|
||||
doc.cards.insert(id.into(), CardEntry { quantity: qty, location: String::new() });
|
||||
apply_doc(&conn, &doc).unwrap();
|
||||
}
|
||||
|
||||
fn qty(&self, id: &str) -> Option<i64> {
|
||||
let conn = mtgc_core::db::open_library(&self.db).unwrap();
|
||||
read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_devices_converge_through_the_real_server() {
|
||||
let (server, state) = spawn_server();
|
||||
let hash = hash_password("correct horse battery").unwrap();
|
||||
db::create_local_user(&state.db(), "alice", "Alice", &hash).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("mtgc-e2e-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let info = server_info(&server).unwrap();
|
||||
assert!(info.local_auth);
|
||||
|
||||
let phone_login = sign_in(&server, "alice", "correct horse battery");
|
||||
let desktop_login = sign_in(&server, "alice", "correct horse battery");
|
||||
assert_eq!(phone_login.user.id, desktop_login.user.id, "same account, same id");
|
||||
assert_eq!(phone_login.user.method, "local");
|
||||
|
||||
let mut phone = Device::new(&dir, "phone", &server, &phone_login.token);
|
||||
let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token);
|
||||
|
||||
phone.set("a", 1);
|
||||
phone.set("b", 1);
|
||||
assert!(phone.sync().unwrap().published);
|
||||
desktop.sync().unwrap();
|
||||
assert_eq!(desktop.qty("a"), Some(1));
|
||||
|
||||
// Offline edits on both, to different cards.
|
||||
phone.set("a", 3);
|
||||
desktop.set("b", 5);
|
||||
phone.sync().unwrap();
|
||||
desktop.sync().unwrap();
|
||||
phone.sync().unwrap();
|
||||
|
||||
for d in [&phone, &desktop] {
|
||||
assert_eq!(d.qty("a"), Some(3));
|
||||
assert_eq!(d.qty("b"), Some(5));
|
||||
}
|
||||
assert!(!phone.sync().unwrap().published, "settled: nothing left to send");
|
||||
|
||||
// Revoking the phone from the desktop signs it out.
|
||||
let me = desktop.remote.me().unwrap();
|
||||
let phone_id = me.devices.iter().find(|d| !d.current).unwrap().id;
|
||||
desktop.remote.revoke_device(phone_id).unwrap();
|
||||
let err = phone.sync().unwrap_err();
|
||||
assert_eq!(err.to_string(), UNAUTHORIZED);
|
||||
|
||||
desktop.remote.logout().unwrap();
|
||||
assert!(desktop.sync().is_err());
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_and_server_agree_on_pkce() {
|
||||
let p = Pkce::new();
|
||||
assert_eq!(mtgc_server::auth::pkce_challenge(&p.verifier), p.challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_that_is_not_an_mtgc_server_is_rejected() {
|
||||
// Something that answers HTTP but isn't us.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{Read, Write};
|
||||
if let Ok((mut s, _)) = listener.accept() {
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf);
|
||||
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");
|
||||
}
|
||||
});
|
||||
let err = server_info(&format!("http://{addr}")).unwrap_err();
|
||||
assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}");
|
||||
}
|
||||
Reference in New Issue
Block a user