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.
187 lines
6.2 KiB
Rust
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());
|
|
}
|
|
}
|