Files
mtgc/crates/mtgc-server/src/auth/local.rs
Garrett Mills de55fc634a 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.
2026-10-08 22:32:08 -05:00

187 lines
6.2 KiB
Rust

//! 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());
}
}