diff --git a/Cargo.lock b/Cargo.lock index 6bf3bdd..eadcffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1210,7 +1210,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openmfa" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "axum", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "openmfa-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "argon2", "base32", @@ -1250,7 +1250,7 @@ dependencies = [ [[package]] name = "openmfa-delivery" -version = "0.1.1" +version = "0.2.0" dependencies = [ "axum", "openmfa-core", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "openmfa-http-api" -version = "0.1.1" +version = "0.2.0" dependencies = [ "axum", "base64 0.22.1", @@ -1282,7 +1282,9 @@ dependencies = [ "rqrr", "serde", "serde_json", + "tempfile", "tokio", + "tower", "tower-http", "tracing", "uuid", @@ -1290,7 +1292,7 @@ dependencies = [ [[package]] name = "openmfa-render" -version = "0.1.1" +version = "0.2.0" dependencies = [ "image", "resvg", @@ -1302,7 +1304,7 @@ dependencies = [ [[package]] name = "openmfa-webui" -version = "0.1.1" +version = "0.2.0" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index 3e14ce0..3a2fa76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2021" rust-version = "1.90" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 9bd1b46..a318a8e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@

The web UI is what you'd expect from Google or Microsoft Authenticator: a -searchable list of accounts, each with its six digits and a countdown ring. +searchable list of accounts behind recognisable service marks, each code +covered until you reveal it, with a countdown ring while it shows. The part that isn't ordinary is the delivery — if you have Cloudflare, Plex and Ubisoft in the vault, your chosen channel holds three messages, each one staying current on its own. @@ -165,6 +166,9 @@ What the app does hold up its end on: to keep it off the data volume entirely — then a leaked copy of that volume is useless on its own. - Everything under `/api/*` requires a session. There is no public read path. +- Codes are **covered until revealed**, per session, and every reveal is + logged with the caller's address — a screen left unattended shows dots, and + a borrowed session leaves a trace. - The UI is fully offline: no CDN, no remote fonts, no external requests. The only outbound traffic the app ever makes is to the chat platform you chose. @@ -216,6 +220,23 @@ of Discord, Slack or Telegram — optional, so you can wire a platform later fro Settings → Delivery. Switching platforms afterwards keeps the other platforms' details, so you can move back and forth without re-pasting tokens. +**Reading a code.** Codes are covered by default — six dots and an eye. Click +to reveal one: the digits appear with their countdown ring and a copy button, +stay for a minute (long enough to survive a rollover), then go back under +cover on their own. A reveal is a request to the server, which is what makes +the log below trustworthy: the browser never holds a code it hasn't asked for +by name, so "covered" is not a CSS trick. + +**Service marks.** An `otpauth://` link carries no artwork — only the issuer, +the label and the secret — so OpenMFA ships a pack of about 175 marks +([Simple Icons](https://simpleicons.org/), plus a few Font Awesome brands for +the ones Simple Icons no longer carries: Amazon, AWS, Microsoft, LinkedIn, +Slack, Xbox, Salesforce, Yahoo) and matches on the issuer name — the whole +name first, then shorter prefixes ("Google Workspace" → Google), then the +label's mail domain (`me@gmail.com` → Gmail). Unmatched accounts keep the +initial tile. `scripts/fetch-icons.py` regenerates the pack; add a slug or an +alias there. + **Adding an account** offers three routes: - **Upload** a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots, @@ -263,6 +284,22 @@ tile; the *Account* field (usually the username or email) is left alone unless you change it. A rename reaches the chat within a second or two — the push loop clears its "already sent" marker so it doesn't wait for the code to roll. +### The log + +**Settings → Log** is an audit trail in the style of Daito: one line per event +— *admin revealed GitHub · miles@example.com* — with how long ago, who, and +from which address underneath. It records what the server did, never what the +browser claims: sign-ins (and failed attempts, under the name that was tried), +every reveal, accounts added, renamed and removed, delivery and credential +changes, test messages. Entries live in `audit.jsonl` beside the vault, +owner-only; the newest 5,000 are kept. + +The address is the TCP peer unless a reverse proxy forwards the real one in +`X-Forwarded-For` or `X-Real-IP`. On Unraid in **bridge** networking the peer +is Docker's gateway (`172.17.0.1`) for every LAN client — put the app behind +Nginx Proxy Manager, SWAG or a Cloudflare tunnel, or run it on `host` or a +custom `br0` network, to see real addresses. + --- ## Configuration @@ -294,11 +331,11 @@ but Text is the frugal choice for many accounts. ``` crates/ core/ TOTP, vault, crypto, settings (incl. provider config), admin auth, - Google Authenticator export parsing — no network + the audit log, Google Authenticator export parsing — no network render/ SVG → WebP tile rendering (resvg + embedded JetBrains Mono) delivery/ Discord, Slack and Telegram clients behind one Sink; the push engine - webui/ Embedded HTML/CSS/JS, compiled into the binary - http-api/ axum router, sessions, QR decoding + webui/ Embedded HTML/CSS/JS and the service-icon pack, compiled into the binary + http-api/ axum router, sessions and reveal windows, QR decoding, the log API openmfa/ The binary ``` @@ -316,7 +353,7 @@ The UI follows [Beautiful UI](https://www.beautifului.dev/) — OKLCH colour ramps, hairline-ring shadows, inset fields against raised buttons, Inter and JetBrains Mono. The accent is monochrome in the vein of [Daito](https://daito.io): ink in light mode, near-white in dark, so the only colour on screen is status -(live, expiring, error) and the three platform dots. Light and dark both ship. +(live, expiring, error) and the service marks. Light and dark both ship. ### Testing @@ -327,7 +364,9 @@ that speak each platform's real error conventions — Slack's `ok:false` on HTTP 200, Telegram's `description` strings, Discord's JSON error codes — asserting that tiles are edited in place rather than re-posted, that deleted tiles are recovered, that dead credentials are surfaced instead of hammered, and that -renames refresh without waiting for a rollover. +renames refresh without waiting for a rollover. The router is exercised end to +end as well: codes stay covered until revealed, a reveal is scoped to the +session that asked, and the log carries the forwarded client address. --- @@ -345,6 +384,11 @@ renames refresh without waiting for a rollover. ## Changelog +- **v0.2** — Covered codes, service marks, and a log. Codes show as dots until + revealed (server-side, per session, for a minute); the account list shows + each service's mark instead of an initial, from a bundled pack of ~175; + **Settings → Log** records reveals, sign-ins and every change with who and + from which address; the setup and delivery pickers are plain text. - **v0.1.1** — Monochrome UI. The accent is ink rather than blue across the primary buttons, the floating **Add an account** button, the empty-state shield, the settings tabs and the focus glow on inputs; the settings tabs @@ -356,4 +400,7 @@ renames refresh without waiting for a rollover. ## Licence MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License -1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`). +1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`). The service-icon pack in +`crates/webui/src/icons.js` is generated from [Simple Icons](https://simpleicons.org/) +(CC0 1.0) and [Font Awesome Free](https://fontawesome.com/license/free) brand +icons (CC BY 4.0); the marks themselves remain trademarks of their owners. diff --git a/crates/core/src/audit.rs b/crates/core/src/audit.rs new file mode 100644 index 0000000..7f4a268 --- /dev/null +++ b/crates/core/src/audit.rs @@ -0,0 +1,427 @@ +//! Append-only audit log: who did what, from where, when. +//! +//! Every entry is one JSON line in `audit.jsonl` under the work directory, +//! written as it happens. The most recent [`KEEP`] entries are also held in +//! memory, which is what the UI reads; the file is compacted back down to +//! that many once it has grown to twice the size, so it can't fill a disk. +//! +//! The log records what the *server* observed — a code was handed out, a +//! session was opened, an account was renamed. Nothing is client-reported, +//! so an entry means the thing actually happened. + +use crate::unix_now_millis; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub const FILE_NAME: &str = "audit.jsonl"; + +/// Entries held in memory and served to the UI. The file is compacted to +/// this many once it reaches [`COMPACT_AT`] lines. +pub const KEEP: usize = 5000; +const COMPACT_AT: usize = KEEP * 2; + +/// Longest actor / target / detail string that is stored. Anything longer is +/// cut, so a hostile client can't bloat the log through a 4 KB username. +const MAX_FIELD: usize = 200; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + /// The admin account was created (first run). + Setup, + Login, + LoginFailed, + Logout, + /// A code was uncovered in the web UI. + Reveal, + AccountAdded, + AccountUpdated, + AccountDeleted, + SettingsUpdated, + TestMessage, + CredentialsUpdated, +} + +impl AuditAction { + pub fn as_str(self) -> &'static str { + match self { + Self::Setup => "setup", + Self::Login => "login", + Self::LoginFailed => "login_failed", + Self::Logout => "logout", + Self::Reveal => "reveal", + Self::AccountAdded => "account_added", + Self::AccountUpdated => "account_updated", + Self::AccountDeleted => "account_deleted", + Self::SettingsUpdated => "settings_updated", + Self::TestMessage => "test_message", + Self::CredentialsUpdated => "credentials_updated", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditEntry { + /// Monotonic within one log; the cursor for paging. + pub id: u64, + /// Milliseconds since the Unix epoch. + pub ts: u64, + /// Username of whoever did it. For a failed sign-in, the name tried. + pub actor: String, + pub action: AuditAction, + /// What it was done to — an account's issuer, usually. + #[serde(default)] + pub target: String, + /// Free text: the account label, what changed, and so on. + #[serde(default)] + pub detail: String, + /// Client address as seen by the server, or as forwarded by a proxy. + #[serde(default)] + pub ip: String, +} + +struct Inner { + entries: VecDeque, + next_id: u64, + /// Lines currently in the file, so compaction can be decided without + /// re-reading it. + on_disk: usize, +} + +#[derive(Clone)] +pub struct AuditLog { + path: PathBuf, + inner: Arc>, +} + +impl std::fmt::Debug for AuditLog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuditLog") + .field("path", &self.path) + .field("entries", &self.len()) + .finish_non_exhaustive() + } +} + +impl AuditLog { + /// Open (or start) the log under `work_dir`. + /// + /// A damaged line is skipped with a warning rather than taking the whole + /// log with it: the audit trail is the one file whose partial loss is + /// preferable to a refusal to start. + pub fn open(work_dir: &Path) -> Self { + let path = work_dir.join(FILE_NAME); + let mut entries: VecDeque = VecDeque::new(); + let mut on_disk = 0usize; + let mut bad = 0usize; + if let Ok(f) = fs::File::open(&path) { + for line in BufReader::new(f).lines().map_while(Result::ok) { + if line.trim().is_empty() { + continue; + } + on_disk += 1; + match serde_json::from_str::(&line) { + Ok(e) => { + entries.push_back(e); + if entries.len() > KEEP { + entries.pop_front(); + } + } + Err(_) => bad += 1, + } + } + } + if bad > 0 { + tracing::warn!(target: "openmfa::audit", skipped = bad, "unreadable audit lines skipped"); + } + let next_id = entries.back().map_or(1, |e| e.id + 1); + Self { + path, + inner: Arc::new(Mutex::new(Inner { + entries, + next_id, + on_disk, + })), + } + } + + /// Record an event. Never fails: a log that can't be written is reported + /// to the process log, and the request that caused it still succeeds. + pub fn record( + &self, + actor: &str, + action: AuditAction, + target: impl Into, + detail: impl Into, + ip: &str, + ) -> AuditEntry { + let mut g = self.inner.lock(); + let entry = AuditEntry { + id: g.next_id, + ts: unix_now_millis(), + actor: clip(actor), + action, + target: clip(&target.into()), + detail: clip(&detail.into()), + ip: clip(ip), + }; + g.next_id += 1; + g.entries.push_back(entry.clone()); + if g.entries.len() > KEEP { + g.entries.pop_front(); + } + self.append(&mut g, &entry); + entry + } + + /// Newest first. `before` is an entry id: only entries older than it are + /// returned, which is how the UI pages backwards. The flag says whether + /// anything older still exists. + pub fn recent(&self, limit: usize, before: Option) -> (Vec, bool) { + let g = self.inner.lock(); + let mut out: Vec = g + .entries + .iter() + .rev() + .filter(|e| before.is_none_or(|b| e.id < b)) + .take(limit + 1) + .cloned() + .collect(); + let more = out.len() > limit; + out.truncate(limit); + (out, more) + } + + pub fn len(&self) -> usize { + self.inner.lock().entries.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn append(&self, g: &mut Inner, entry: &AuditEntry) { + if g.on_disk >= COMPACT_AT { + self.rewrite(g); + return; + } + let line = match serde_json::to_string(entry) { + Ok(l) => l, + Err(e) => { + tracing::error!(target: "openmfa::audit", "could not serialize audit entry: {e}"); + return; + } + }; + match open_append(&self.path).and_then(|mut f| writeln!(f, "{line}")) { + Ok(()) => g.on_disk += 1, + Err(e) => { + tracing::error!(target: "openmfa::audit", "could not write the audit log: {e}"); + } + } + } + + /// Replace the file with the in-memory window, atomically. + fn rewrite(&self, g: &mut Inner) { + let mut body = String::new(); + for e in &g.entries { + if let Ok(l) = serde_json::to_string(e) { + body.push_str(&l); + body.push('\n'); + } + } + let tmp = self.path.with_extension("jsonl.tmp"); + let result = open_truncate(&tmp) + .and_then(|mut f| f.write_all(body.as_bytes())) + .and_then(|()| fs::rename(&tmp, &self.path)); + match result { + Ok(()) => g.on_disk = g.entries.len(), + Err(e) => { + tracing::error!(target: "openmfa::audit", "could not compact the audit log: {e}"); + } + } + } +} + +fn clip(s: &str) -> String { + // Control characters would let one entry masquerade as several when the + // file is read by eye; keep the text but drop those. + let cleaned: String = s.chars().filter(|c| !c.is_control()).collect(); + if cleaned.chars().count() <= MAX_FIELD { + cleaned + } else { + cleaned.chars().take(MAX_FIELD).collect::() + "…" + } +} + +/// Owner-only, like the vault: the log names accounts and addresses. +fn private_options() -> OpenOptions { + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts +} + +fn open_append(path: &Path) -> std::io::Result { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + private_options().create(true).append(true).open(path) +} + +fn open_truncate(path: &Path) -> std::io::Result { + private_options() + .create(true) + .write(true) + .truncate(true) + .open(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn fill(log: &AuditLog, n: usize) { + for i in 0..n { + log.record( + "admin", + AuditAction::Reveal, + format!("Acct {i}"), + "", + "10.0.0.1", + ); + } + } + + #[test] + fn records_newest_first_and_pages_backwards() { + let dir = tempdir().unwrap(); + let log = AuditLog::open(dir.path()); + fill(&log, 5); + + let (page, more) = log.recent(2, None); + assert_eq!(page.len(), 2); + assert!(more); + assert_eq!(page[0].target, "Acct 4"); + assert_eq!(page[1].target, "Acct 3"); + + let (page2, more2) = log.recent(10, Some(page[1].id)); + assert_eq!( + page2.iter().map(|e| e.target.as_str()).collect::>(), + ["Acct 2", "Acct 1", "Acct 0"] + ); + assert!(!more2); + } + + #[test] + fn survives_a_reopen_and_keeps_ids_monotonic() { + let dir = tempdir().unwrap(); + { + let log = AuditLog::open(dir.path()); + log.record("admin", AuditAction::Login, "", "", "192.168.1.5"); + log.record( + "admin", + AuditAction::Reveal, + "GitHub", + "me@example.com", + "192.168.1.5", + ); + } + let log = AuditLog::open(dir.path()); + assert_eq!(log.len(), 2); + let e = log.record("admin", AuditAction::Logout, "", "", "192.168.1.5"); + assert_eq!(e.id, 3, "ids continue across restarts"); + let (page, _) = log.recent(1, None); + assert_eq!(page[0].action, AuditAction::Logout); + assert_eq!(page[0].ip, "192.168.1.5"); + } + + #[test] + fn skips_a_damaged_line_instead_of_failing() { + let dir = tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let good = serde_json::to_string(&AuditEntry { + id: 1, + ts: 1, + actor: "admin".into(), + action: AuditAction::Setup, + target: String::new(), + detail: String::new(), + ip: "127.0.0.1".into(), + }) + .unwrap(); + fs::write(&path, format!("{good}\nthis is not json\n\n")).unwrap(); + let log = AuditLog::open(dir.path()); + assert_eq!(log.len(), 1); + assert_eq!(log.record("a", AuditAction::Login, "", "", "x").id, 2); + } + + #[test] + fn compacts_the_file_once_it_doubles() { + let dir = tempdir().unwrap(); + let log = AuditLog::open(dir.path()); + fill(&log, COMPACT_AT + 5); + let lines = fs::read_to_string(dir.path().join(FILE_NAME)) + .unwrap() + .lines() + .count(); + assert!( + lines <= KEEP + 5, + "file should have been compacted, has {lines} lines" + ); + assert_eq!(log.len(), KEEP); + // What is on disk is exactly the in-memory window, newest last. + let reopened = AuditLog::open(dir.path()); + assert_eq!( + reopened.len(), + KEEP, + "a reopen keeps the newest KEEP entries" + ); + assert_eq!( + reopened.recent(1, None).0[0].target, + format!("Acct {}", COMPACT_AT + 4) + ); + } + + #[test] + fn clips_long_and_control_laden_fields() { + let dir = tempdir().unwrap(); + let log = AuditLog::open(dir.path()); + let long = "x".repeat(400); + let e = log.record("ad\nmin", AuditAction::Login, long.as_str(), "", ""); + assert_eq!(e.actor, "admin"); + assert_eq!(e.target.chars().count(), MAX_FIELD + 1); + assert!(e.target.ends_with('…')); + } + + #[cfg(unix)] + #[test] + fn file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let log = AuditLog::open(dir.path()); + log.record("admin", AuditAction::Login, "", "", ""); + let mode = fs::metadata(dir.path().join(FILE_NAME)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn actions_round_trip_through_serde_as_snake_case() { + let s = serde_json::to_string(&AuditAction::LoginFailed).unwrap(); + assert_eq!(s, "\"login_failed\""); + assert_eq!(AuditAction::LoginFailed.as_str(), "login_failed"); + let back: AuditAction = serde_json::from_str("\"account_added\"").unwrap(); + assert_eq!(back, AuditAction::AccountAdded); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b3efe06..436d167 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,6 +7,7 @@ //! tested without a socket in sight. #![forbid(unsafe_code)] +pub mod audit; pub mod auth; pub mod crypto; pub mod error; @@ -15,6 +16,7 @@ pub mod settings; pub mod totp; pub mod vault; +pub use audit::{AuditAction, AuditEntry, AuditLog}; pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use crypto::{Sealed, VaultKey}; pub use error::{Error, Result}; diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 2978b53..d1e06e9 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -30,3 +30,5 @@ workspace = true [dev-dependencies] qrcode = { version = "0.14", default-features = false, features = ["image"] } base64.workspace = true +tower = { version = "0.5", features = ["util"] } +tempfile = "3" diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 8accd32..29a39fe 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -1,23 +1,29 @@ //! Router, asset serving, and the account / code / settings endpoints. -use crate::auth; +use crate::auth::{self, Caller}; use crate::state::AppState; use axum::{ - extract::{DefaultBodyLimit, Multipart, Path, State}, + extract::{DefaultBodyLimit, Multipart, Path, Query, State}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use openmfa_core as da; -use openmfa_core::{unix_now, unix_now_millis, Settings}; +use openmfa_core::{unix_now, unix_now_millis, AuditAction, Provider, Settings, Totp}; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; /// Cap on an uploaded QR image. Generous enough for a full phone photo, /// small enough that it can't be used to exhaust memory. const MAX_UPLOAD: usize = 12 * 1024 * 1024; +/// How long a revealed code stays uncovered. Two periods: long enough to +/// type it and to survive a rollover mid-way, short enough that a screen +/// left unattended goes back to dots on its own. +const REVEAL_TTL: Duration = Duration::from_secs(60); + pub fn router(state: AppState) -> Router { let api = Router::new() .route("/api/me", get(auth::api_me)) @@ -32,7 +38,10 @@ pub fn router(state: AppState) -> Router { "/api/accounts/{id}", axum::routing::patch(update_account).delete(delete_account), ) + .route("/api/accounts/{id}/reveal", post(reveal_account)) + .route("/api/accounts/{id}/hide", post(hide_account)) .route("/api/codes", get(list_codes)) + .route("/api/log", get(list_log)) .route( "/api/qr/decode", post(decode_qr).layer(DefaultBodyLimit::max(MAX_UPLOAD)), @@ -48,6 +57,7 @@ pub fn router(state: AppState) -> Router { .route("/", get(index)) .route("/assets/app.js", get(asset_js)) .route("/assets/app.css", get(asset_css)) + .route("/assets/icons.js", get(asset_icons)) .route("/assets/logo.svg", get(asset_logo)) .route("/healthz", get(|| async { "ok" })) .layer(axum::middleware::from_fn_with_state( @@ -97,6 +107,18 @@ async fn asset_css() -> Response { .into_response() } +/// The bundled service-icon pack — generated, see `scripts/fetch-icons.py`. +async fn asset_icons() -> Response { + ( + [ + (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), + (header::CACHE_CONTROL, CACHE), + ], + openmfa_webui::icons_js(), + ) + .into_response() +} + async fn asset_logo() -> Response { ( [ @@ -192,7 +214,11 @@ struct ImportBody { /// server re-parses it. That keeps secrets from ever making the round trip /// out to the browser and back, which they would if the preview had returned /// them. -async fn import_accounts(State(state): State, Json(body): Json) -> Response { +async fn import_accounts( + State(state): State, + caller: Caller, + Json(body): Json, +) -> Response { let batch = match da::parse_import(&body.uri) { Ok(b) => b, Err(e) => return crate::error_response(&e), @@ -217,7 +243,18 @@ async fn import_accounts(State(state): State, Json(body): Json (StatusCode::CREATED, Json(outcome)).into_response(), + Ok(outcome) => { + for a in &outcome.added { + state.audit.record( + caller.name(), + AuditAction::AccountAdded, + &a.issuer, + &a.label, + &caller.ip, + ); + } + (StatusCode::CREATED, Json(outcome)).into_response() + } Err(e) => crate::error_response(&e), } } @@ -234,23 +271,66 @@ struct UpdateBody { async fn update_account( State(state): State, + caller: Caller, Path(id): Path, Json(body): Json, ) -> Response { + let before = state.vault.get(&id); match state.vault.update( &id, body.issuer.as_deref(), body.label.as_deref(), body.push_enabled, ) { - Ok(a) => Json(a).into_response(), + Ok(a) => { + // Say what changed, so the log reads "renamed from X" rather than + // an unhelpful "updated". + let mut changes = Vec::new(); + if let Some(b) = &before { + if b.issuer != a.issuer { + changes.push(format!("renamed from {}", b.issuer)); + } + if b.label != a.label { + changes.push(format!("account changed from {}", b.label)); + } + if b.push_enabled != a.push_enabled { + changes.push(if a.push_enabled { + "push on".to_string() + } else { + "push off".to_string() + }); + } + } + state.audit.record( + caller.name(), + AuditAction::AccountUpdated, + &a.issuer, + changes.join(", "), + &caller.ip, + ); + Json(a).into_response() + } Err(e) => crate::error_response(&e), } } -async fn delete_account(State(state): State, Path(id): Path) -> Response { +async fn delete_account( + State(state): State, + caller: Caller, + Path(id): Path, +) -> Response { + let gone = state.vault.get(&id); match state.vault.remove(&id) { Ok(message_id) => { + if let Some(g) = &gone { + state.audit.record( + caller.name(), + AuditAction::AccountDeleted, + &g.issuer, + &g.label, + &caller.ip, + ); + } // Take the tile down too, best-effort and off the response path — // the account is already gone from the vault either way. if let Some(mid) = message_id { @@ -269,28 +349,45 @@ fn default_true() -> bool { // ── Codes ───────────────────────────────────────────────────────────────── -/// Current and next code for every account, plus the server clock. +/// One account's current and next code, plus when the reveal lapses. /// /// Returning the *next* code as well is what lets the browser roll over at a /// period boundary with no request in flight — one fetch covers two periods, -/// so the digits never blank out. Shipping the server's clock lets the UI +/// so the digits never blank out. +fn code_json(id: &str, totp: &Totp, now: u64, until: Instant) -> Value { + let remaining = until.saturating_duration_since(Instant::now()); + json!({ + "id": id, + "code": totp.code_at(now), + "next": totp.next_code_at(now), + "period": totp.period, + // Milliseconds, to pair with the JS clock. + "expires_at": totp.expires_at(now) * 1000, + "revealed_until": unix_now_millis() + remaining.as_millis() as u64, + }) +} + +/// Codes for the accounts this session has revealed, plus the server clock. +/// +/// Everything else stays covered: the browser never receives a code it has +/// not asked for by name, so "hidden" means hidden and every code that was +/// ever shown has a line in the log. Shipping the server's clock lets the UI /// drive its countdown off our time rather than the viewing device's, which /// may be minutes off. -async fn list_codes(State(state): State) -> Response { +async fn list_codes(State(state): State, caller: Caller) -> Response { + let revealed = caller + .token + .as_deref() + .map(|t| state.sessions.revealed(t)) + .unwrap_or_default(); let now = unix_now(); - let codes: Vec<_> = state + let codes: Vec = state .vault .all_with_totp() .into_iter() - .map(|(account, totp)| { - json!({ - "id": account.id, - "code": totp.code_at(now), - "next": totp.next_code_at(now), - "period": totp.period, - // Milliseconds, to pair with the JS clock. - "expires_at": totp.expires_at(now) * 1000, - }) + .filter_map(|(account, totp)| { + let until = revealed.get(&account.id)?; + Some(code_json(&account.id, &totp, now, *until)) }) .collect(); @@ -301,6 +398,79 @@ async fn list_codes(State(state): State) -> Response { .into_response() } +/// Uncover one account's code for this session and write that down. +async fn reveal_account( + State(state): State, + caller: Caller, + Path(id): Path, +) -> Response { + let Some(account) = state.vault.get(&id) else { + return crate::error_response(&da::Error::not_found("account")); + }; + let until = caller + .token + .as_deref() + .and_then(|t| state.sessions.reveal(t, &id, REVEAL_TTL)); + let Some(until) = until else { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "authentication required" })), + ) + .into_response(); + }; + let totp = match state.vault.totp_for(&id) { + Ok(t) => t, + Err(e) => return crate::error_response(&e), + }; + state.audit.record( + caller.name(), + AuditAction::Reveal, + &account.issuer, + &account.label, + &caller.ip, + ); + ( + [(header::CACHE_CONTROL, "no-store")], + Json(code_json(&id, &totp, unix_now(), until)), + ) + .into_response() +} + +/// Cover a code again before its window lapses. Not logged: nothing was +/// disclosed by hiding. +async fn hide_account( + State(state): State, + caller: Caller, + Path(id): Path, +) -> Response { + if let Some(t) = caller.token.as_deref() { + state.sessions.hide(t, &id); + } + StatusCode::NO_CONTENT.into_response() +} + +// ── Log ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct LogQuery { + #[serde(default)] + limit: Option, + /// Entry id; only older entries are returned. From the previous page's + /// last entry. + #[serde(default)] + before: Option, +} + +async fn list_log(State(state): State, Query(q): Query) -> Response { + let limit = q.limit.unwrap_or(100).clamp(1, 500); + let (entries, has_more) = state.audit.recent(limit, q.before); + ( + [(header::CACHE_CONTROL, "no-store")], + Json(json!({ "entries": entries, "has_more": has_more })), + ) + .into_response() +} + // ── QR ──────────────────────────────────────────────────────────────────── /// Decode an uploaded image and return the first `otpauth://` payload. @@ -376,7 +546,11 @@ async fn get_settings(State(state): State) -> Response { .into_response() } -async fn put_settings(State(state): State, Json(incoming): Json) -> Response { +async fn put_settings( + State(state): State, + caller: Caller, + Json(incoming): Json, +) -> Response { match state.settings.replace(incoming) { Ok((s, destination_changed)) => { if destination_changed { @@ -393,15 +567,40 @@ async fn put_settings(State(state): State, Json(incoming): Json crate::error_response(&e), } } -async fn test_delivery(State(state): State) -> Response { +async fn test_delivery(State(state): State, caller: Caller) -> Response { match state.engine.send_test().await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), + Ok(()) => { + let provider = state + .settings + .snapshot() + .provider + .map_or("no platform", Provider::display_name); + state.audit.record( + caller.name(), + AuditAction::TestMessage, + provider, + "", + &caller.ip, + ); + StatusCode::NO_CONTENT.into_response() + } Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(), } } diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index 3a54707..049c4d0 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -14,18 +14,20 @@ use crate::state::AppState; use axum::{ body::Body, - extract::{Request, State}, - http::{header, HeaderValue, StatusCode}, + extract::{ConnectInfo, FromRequestParts, Request, State}, + http::{header, request::Parts, HeaderValue, StatusCode}, middleware::Next, response::{IntoResponse, Response}, Json, }; use openmfa_core as da; -use openmfa_core::AdminPublic; +use openmfa_core::{AdminPublic, AuditAction}; use parking_lot::RwLock; use serde::Deserialize; use serde_json::json; use std::collections::HashMap; +use std::convert::Infallible; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -38,6 +40,9 @@ pub const SESSION_COOKIE: &str = "openmfa_session"; struct Session { username: String, expires_at: Instant, + /// Accounts whose codes this session may currently see, and until when. + /// Codes are covered by default; a reveal opens one for a short window. + revealed: HashMap, } /// In-memory session table. Sessions deliberately do not survive a restart — @@ -58,6 +63,7 @@ impl SessionStore { Session { username: username.to_string(), expires_at: Instant::now() + SESSION_TTL, + revealed: HashMap::new(), }, ); id @@ -80,6 +86,46 @@ impl SessionStore { self.inner.write().remove(id); } + /// Name on a token without extending its idle timer. + pub fn peek(&self, id: &str) -> Option { + let g = self.inner.read(); + let s = g.get(id)?; + (s.expires_at > Instant::now()).then(|| s.username.clone()) + } + + /// Let this session see one account's code for `ttl`. Returns the + /// deadline, or `None` when the token is not a live session. + pub fn reveal(&self, id: &str, account_id: &str, ttl: Duration) -> Option { + let mut g = self.inner.write(); + let s = g.get_mut(id)?; + if s.expires_at <= Instant::now() { + g.remove(id); + return None; + } + let until = Instant::now() + ttl; + s.revealed.insert(account_id.to_string(), until); + Some(until) + } + + /// Cover a code again ahead of its deadline. + pub fn hide(&self, id: &str, account_id: &str) { + if let Some(s) = self.inner.write().get_mut(id) { + s.revealed.remove(account_id); + } + } + + /// Accounts this session may see codes for right now, with deadlines. + /// Expired reveals are dropped on the way out. + pub fn revealed(&self, id: &str) -> HashMap { + let mut g = self.inner.write(); + let Some(s) = g.get_mut(id) else { + return HashMap::new(); + }; + let now = Instant::now(); + s.revealed.retain(|_, until| *until > now); + s.revealed.clone() + } + /// Invalidate everything — used after a credential change so a cookie /// minted under the old password stops working. pub fn revoke_all(&self) { @@ -161,6 +207,65 @@ pub async fn require_auth( .into_response() } +// ── Caller ──────────────────────────────────────────────────────────────── + +/// Who is asking, and from where — what the audit log writes down. +#[derive(Debug, Clone)] +pub struct Caller { + pub token: Option, + pub username: Option, + pub ip: String, +} + +impl Caller { + pub fn name(&self) -> &str { + self.username.as_deref().unwrap_or("unknown") + } +} + +impl FromRequestParts for Caller { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let token = parse_cookie(&parts.headers); + let username = token.as_deref().and_then(|t| state.sessions.peek(t)); + let peer = parts + .extensions + .get::>() + .map(|c| c.0); + Ok(Self { + token, + username, + ip: client_ip(&parts.headers, peer), + }) + } +} + +/// The address to write in the log. +/// +/// A reverse proxy (Nginx Proxy Manager, SWAG, a Cloudflare tunnel) puts the +/// real client in `X-Forwarded-For` / `X-Real-IP`; those win when they carry +/// a parseable address. Otherwise it is the TCP peer — which, inside Docker's +/// bridge network, is the gateway rather than the LAN client. The headers are +/// trusted as-is: the only party who can send them straight to this port is +/// the signed-in admin, and a log they could lie to about their own address +/// is not worth a configuration knob. +pub fn client_ip(headers: &axum::http::HeaderMap, peer: Option) -> String { + for name in ["x-forwarded-for", "x-real-ip"] { + let Some(v) = headers.get(name).and_then(|v| v.to_str().ok()) else { + continue; + }; + if let Some(ip) = v + .split(',') + .map(str::trim) + .find_map(|s| s.parse::().ok()) + { + return ip.to_string(); + } + } + peer.map_or_else(|| "unknown".to_string(), |p| p.ip().to_string()) +} + // ── Handlers ────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] @@ -181,7 +286,11 @@ pub struct SetupBody { /// First-run setup: create the admin and, optionally, wire a chat platform in /// one step so the operator lands on a working install. -pub async fn api_setup(State(state): State, Json(body): Json) -> Response { +pub async fn api_setup( + State(state): State, + caller: Caller, + Json(body): Json, +) -> Response { if state.admin.is_configured() { return ( StatusCode::CONFLICT, @@ -218,6 +327,9 @@ pub async fn api_setup(State(state): State, Json(body): Json, Json(body): Json) -> Response { +pub async fn api_login( + State(state): State, + caller: Caller, + Json(body): Json, +) -> Response { let admin = state.admin.clone(); + let attempted = body.username.trim().to_string(); let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password)) .await .unwrap_or_else(|e| Err(da::Error::crypto(e))); match verdict { Ok(Some(public)) => { + state + .audit + .record(&public.username, AuditAction::Login, "", "", &caller.ip); let token = state.sessions.create(&public.username); session_response(StatusCode::OK, &public, &token) } // Deliberately vague: no distinction between "no such user" and - // "wrong password", so the endpoint can't be used to enumerate. - Ok(None) => ( - StatusCode::UNAUTHORIZED, - Json(json!({ "error": "invalid username or password" })), - ) - .into_response(), + // "wrong password", so the endpoint can't be used to enumerate. The + // attempt is logged under the name that was tried. + Ok(None) => { + state + .audit + .record(&attempted, AuditAction::LoginFailed, "", "", &caller.ip); + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid username or password" })), + ) + .into_response() + } Err(e) => crate::error_response(&e), } } -pub async fn api_logout(State(state): State, headers: axum::http::HeaderMap) -> Response { +pub async fn api_logout( + State(state): State, + caller: Caller, + headers: axum::http::HeaderMap, +) -> Response { if let Some(t) = parse_cookie(&headers) { + if let Some(name) = &caller.username { + state + .audit + .record(name, AuditAction::Logout, "", "", &caller.ip); + } state.sessions.revoke(&t); } let mut resp = StatusCode::NO_CONTENT.into_response(); @@ -308,9 +443,16 @@ pub struct UpdateCredentialsBody { /// the keyboard is the owner and not someone using a borrowed browser. pub async fn api_update_credentials( State(state): State, + caller: Caller, Json(body): Json, ) -> Response { let admin = state.admin.clone(); + let what = match (body.new_username.is_some(), body.new_password.is_some()) { + (true, true) => "username and password", + (true, false) => "username", + (false, true) => "password", + (false, false) => "nothing", + }; let result = tokio::task::spawn_blocking(move || { admin.update_credentials( &body.current_password, @@ -323,6 +465,13 @@ pub async fn api_update_credentials( match result { Ok(public) => { + state.audit.record( + &public.username, + AuditAction::CredentialsUpdated, + "", + what, + &caller.ip, + ); // Every prior cookie dies with the old password. state.sessions.revoke_all(); let token = state.sessions.create(&public.username); @@ -411,6 +560,60 @@ mod tests { assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none()); } + #[test] + fn reveals_are_per_session_and_expire() { + let s = SessionStore::default(); + let t = s.create("admin"); + assert!(s.revealed(&t).is_empty(), "nothing is visible by default"); + + let until = s.reveal(&t, "acct-1", Duration::from_secs(60)).unwrap(); + assert!(until > Instant::now()); + assert!(s.revealed(&t).contains_key("acct-1")); + + // A different session sees nothing. + let other = s.create("admin"); + assert!(s.revealed(&other).is_empty()); + + // Hide is immediate. + s.hide(&t, "acct-1"); + assert!(s.revealed(&t).is_empty()); + + // A zero TTL is expired on the next read. + s.reveal(&t, "acct-2", Duration::ZERO).unwrap(); + assert!(s.revealed(&t).is_empty()); + + // Dead tokens cannot reveal. + assert!(s.reveal("nope", "acct-1", Duration::from_secs(1)).is_none()); + } + + #[test] + fn client_ip_prefers_a_forwarded_address_then_falls_back_to_the_peer() { + let peer: SocketAddr = "172.17.0.1:54321".parse().unwrap(); + let mut h = axum::http::HeaderMap::new(); + assert_eq!(client_ip(&h, Some(peer)), "172.17.0.1"); + assert_eq!(client_ip(&h, None), "unknown"); + + h.insert("x-real-ip", HeaderValue::from_static("192.168.1.123")); + assert_eq!(client_ip(&h, Some(peer)), "192.168.1.123"); + + // X-Forwarded-For wins over X-Real-IP, and its first hop is the client. + h.insert( + "x-forwarded-for", + HeaderValue::from_static("203.0.113.9, 10.0.0.2"), + ); + assert_eq!(client_ip(&h, Some(peer)), "203.0.113.9"); + + // Garbage in the header is not copied into the log. + h.insert( + "x-forwarded-for", + HeaderValue::from_static(" diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs index ddab700..fb185c3 100644 --- a/crates/webui/src/lib.rs +++ b/crates/webui/src/lib.rs @@ -32,7 +32,15 @@ pub fn logo_svg() -> &'static str { LOGO_SVG } +/// The service-icon pack (generated by `scripts/fetch-icons.py`): brand +/// marks for the account list, matched by issuer name in the browser. +#[must_use] +pub fn icons_js() -> &'static str { + ICONS_JS +} + const INDEX_HTML: &str = include_str!("index.html"); const APP_CSS: &str = include_str!("app.css"); const APP_JS: &str = include_str!("app.js"); const LOGO_SVG: &str = include_str!("logo.svg"); +const ICONS_JS: &str = include_str!("icons.js"); diff --git a/deploy/unraid/openmfa.xml b/deploy/unraid/openmfa.xml index 4baebb0..8f2000b 100644 --- a/deploy/unraid/openmfa.xml +++ b/deploy/unraid/openmfa.xml @@ -27,8 +27,11 @@ Add accounts by uploading a QR code, scanning with the camera, pasting an otpauth:// link, or importing a Google Authenticator export (one export QR carries many accounts; you get a checklist, and duplicates are detected). - The web UI lists every account with its rotating code, the way Google or - Microsoft Authenticator does. + The web UI lists every account behind its service mark, the way Google or + Microsoft Authenticator does. Codes stay covered until you reveal one, and + Settings -> Log records every reveal and sign-in with who and from where. + (In bridge networking the address is Docker's gateway; use a reverse proxy + or host/br0 networking to see real LAN addresses.) The distinctive part: each account keeps ONE message in your chosen channel, rewritten in place every time the code rolls. Three accounts means diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 1ba69a7..e184d72 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -21,11 +21,11 @@ Bump `version` in the workspace `Cargo.toml`, update the `IMAGE_TAG` and README changelog, and commit. Then tag: ```bash -git tag -a v0.1.1 -m "OpenMFA v0.1.1" +git tag -a v0.2 -m "OpenMFA v0.2" ``` -Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.1.1`, … — and the image carries -the same number without the `v` (`0.1`, `0.1.1`) plus a moving `latest`. +Tags are `vMAJOR.MINOR[.PATCH]` — `v0.1`, `v0.2`, … — and the image carries +the same number without the `v` (`0.1`, `0.1.1`, `0.2`) plus a moving `latest`. ## 2. Push the code and the tag @@ -44,7 +44,7 @@ architecture the builder runs on, so this is fast on Apple Silicon — no QEMU. attestation manifests BuildKit attaches by default. ```bash -docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.1.1 -t gitea.milesward.dev/mward4/openmfa:latest . +docker build --pull --platform linux/amd64 --provenance=false --sbom=false -f deploy/docker/Dockerfile -t gitea.milesward.dev/mward4/openmfa:0.2 -t gitea.milesward.dev/mward4/openmfa:latest . ``` ## 4. Push the image @@ -58,7 +58,7 @@ with a bare 401. Write the auth into a throwaway config instead — a config that already contains an `auths` entry makes the CLI skip the helper: ```bash -export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.1.1 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG +export DOCKER_CONFIG="$(mktemp -d)" && printf '{"auths":{"gitea.milesward.dev":{"auth":"%s"}}}' "$(printf 'mward4:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')" > "$DOCKER_CONFIG/config.json" && docker push gitea.milesward.dev/mward4/openmfa:0.2 && docker push gitea.milesward.dev/mward4/openmfa:latest; rm -rf "$DOCKER_CONFIG"; unset DOCKER_CONFIG ``` On a Colima host, `export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"` @@ -78,7 +78,7 @@ The repo is public, so this needs no credentials — which is exactly how Unraid will fetch it: ```bash -docker pull gitea.milesward.dev/mward4/openmfa:0.1.1 +docker pull gitea.milesward.dev/mward4/openmfa:0.2 ``` ## 6. Deploy on Unraid @@ -86,7 +86,7 @@ docker pull gitea.milesward.dev/mward4/openmfa:0.1.1 Add a container with Repository set to: ``` -gitea.milesward.dev/mward4/openmfa:0.1.1 +gitea.milesward.dev/mward4/openmfa:0.2 ``` (or `:latest`). Map `/var/lib/openmfa` to `/mnt/user/appdata/openmfa`, diff --git a/scripts/build-and-publish-unraid.sh b/scripts/build-and-publish-unraid.sh index e879ae4..e8d0038 100644 --- a/scripts/build-and-publish-unraid.sh +++ b/scripts/build-and-publish-unraid.sh @@ -13,9 +13,9 @@ # GITEA_OWNER default: mward4 # GITEA_REPO default: OpenMFA # IMAGE_NAME default: openmfa -# IMAGE_TAG default: 0.1.1 (also pushed as :latest) +# IMAGE_TAG default: 0.2 (also pushed as :latest) # PLATFORM default: linux/amd64 (Unraid is x86_64) -# GIT_REF default: v0.1.1 (tag/branch to build) +# GIT_REF default: v0.2 (tag/branch to build) # WORKDIR default: /tmp/openmfa-build (removed on exit) # # After this, set the Unraid template's Repository to: @@ -27,9 +27,9 @@ GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev} GITEA_OWNER=${GITEA_OWNER:-mward4} GITEA_REPO=${GITEA_REPO:-OpenMFA} IMAGE_NAME=${IMAGE_NAME:-openmfa} -IMAGE_TAG=${IMAGE_TAG:-0.1.1} +IMAGE_TAG=${IMAGE_TAG:-0.2} PLATFORM=${PLATFORM:-linux/amd64} -GIT_REF=${GIT_REF:-v0.1.1} +GIT_REF=${GIT_REF:-v0.2} WORKDIR=${WORKDIR:-/tmp/openmfa-build} # OCI distribution rejects uppercase repository paths, so the image name is diff --git a/scripts/fetch-icons.py b/scripts/fetch-icons.py new file mode 100755 index 0000000..e0ca434 --- /dev/null +++ b/scripts/fetch-icons.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Regenerate crates/webui/src/icons.js — the bundled service-icon pack. + +The web UI is fully offline, so the icons are fetched here, once, and +committed as a generated JavaScript file that the binary embeds. Two sources: + + * Simple Icons (CC0-1.0) — the bulk of the pack, with brand colours + * Font Awesome Free brands (CC BY 4.0) — a handful of brands that Simple + Icons no longer carries (Amazon, AWS, Microsoft, LinkedIn, Slack, ...) + +Brands remain trademarks of their owners; the pack exists so the account list +can show a recognisable mark instead of an initial. Run with no arguments: + + python3 scripts/fetch-icons.py +""" +import json +import re +import sys +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +SI_VERSION = "16.30.0" +FA_VERSION = "6.7.2" +SI_CDN = f"https://cdn.jsdelivr.net/npm/simple-icons@{SI_VERSION}" +FA_CDN = f"https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@{FA_VERSION}/svgs/brands" +OUT = Path(__file__).resolve().parent.parent / "crates/webui/src/icons.js" + +# Simple Icons slugs. Roughly the services people actually keep in an +# authenticator, weighted towards the homelab / developer crowd. +SIMPLE = """ +github google apple facebook meta instagram x discord telegram dropbox box +cloudflare digitalocean netlify vercel gitlab bitbucket atlassian jira trello +notion evernote zoom twitch reddit pinterest snapchat tiktok steam epicgames +playstation ubisoft ea battledotnet riotgames paypal stripe coinbase binance +robinhood venmo wise revolut shopify wordpress squarespace wix godaddy +namecheap porkbun mailchimp hubspot zendesk okta auth0 1password bitwarden +lastpass dashlane keeper nordvpn protonmail proton protonvpn tuta zoho mega +backblaze synology unraid plex nextcloud homeassistant tailscale wireguard +anthropic claude huggingface docker npm pypi rubygems kubernetes akamai vultr +hetzner ovh figma spotify netflix uber airbnb ebay etsy patreon kickstarter +substack medium tumblr mastodon bluesky threads whatsapp signal wikipedia +ubiquiti nvidia intel amd samsung sony cisco mailgun icloud gmail yubico +duolingo coursera udemy ghost hostinger render railway flydotio supabase +firebase mongodb redis elastic datadog newrelic sentry grafana pagerduty +zapier ifttt airtable asana clickup basecamp linear todoist miro dribbble +behance vimeo youtube cashapp americanexpress kucoin trezor opensea +humblebundle rockstargames squareenix roblox gogdotcom googledrive +googlecloud googleplay intuit quickbooks xero square chase bankofamerica +wellsfargo +""".split() + +# Font Awesome brand icons filling Simple Icons' gaps: (fa name, title, colour). +FONT_AWESOME = [ + ("amazon", "Amazon", "#FF9900"), + ("aws", "AWS", "#FF9900"), + ("microsoft", "Microsoft", "#737373"), + ("linkedin", "LinkedIn", "#0A66C2"), + ("slack", "Slack", "#4A154B"), + ("xbox", "Xbox", "#107C10"), + ("salesforce", "Salesforce", "#00A1E0"), + ("yahoo", "Yahoo", "#6001D2"), +] + +# Extra ways people spell an issuer → slug. Keys are matched after +# normalisation (lower-case, non-alphanumerics dropped), so "Battle.net", +# "battle net" and "BattleNet" all collapse to "battlenet". +ALIASES = { + # Google + "gmail": "gmail", "googleworkspace": "google", "googleaccount": "google", + "gsuite": "google", "googlecloudplatform": "googlecloud", "gcp": "googlecloud", + # Microsoft + "microsoft365": "microsoft", "office365": "microsoft", "office": "microsoft", + "outlook": "microsoft", "outlookcom": "microsoft", "hotmail": "microsoft", + "hotmailcom": "microsoft", "live": "microsoft", "livecom": "microsoft", + "azure": "microsoft", "microsoftazure": "microsoft", "entra": "microsoft", + "onedrive": "microsoft", "xboxlive": "xbox", "microsoftaccount": "microsoft", + # Amazon + "amazonwebservices": "aws", "amazoncom": "amazon", "amazonaws": "aws", + "awsiam": "aws", "amazonprime": "amazon", "primevideo": "amazon", + # Apple + "appleid": "apple", "icloudcom": "icloud", "mecom": "icloud", + # Meta + "fb": "facebook", "ig": "instagram", "twitter": "x", "xcom": "x", + "xtwitter": "x", "twittercom": "x", "whatsappbusiness": "whatsapp", + # Gaming + "blizzard": "battledotnet", "battlenet": "battledotnet", "battle": "battledotnet", + "epic": "epicgames", "epicgamesstore": "epicgames", "riot": "riotgames", + "leagueoflegends": "riotgames", "valorant": "riotgames", "electronicarts": "ea", + "eaaccount": "ea", "origin": "ea", "psn": "playstation", "playstationnetwork": "playstation", + "sonyplaystation": "playstation", "valve": "steam", "steampowered": "steam", + "gog": "gogdotcom", "gogcom": "gogdotcom", "rockstar": "rockstargames", + "socialclub": "rockstargames", "humble": "humblebundle", "ubisoftconnect": "ubisoft", + "uplay": "ubisoft", + # Dev / infra + "githubcom": "github", "gitlabcom": "gitlab", "dockerhub": "docker", + "npmjs": "npm", "npmjscom": "npm", "pypiorg": "pypi", "rubygemsorg": "rubygems", + "k8s": "kubernetes", "do": "digitalocean", "digitalocean": "digitalocean", + "flyio": "flydotio", "fly": "flydotio", "ovhcloud": "ovh", "hetznercloud": "hetzner", + "hetzneronline": "hetzner", "linode": "akamai", "akamailinode": "akamai", + "cloudflareaccess": "cloudflare", "cfaccess": "cloudflare", "huggingfaceco": "huggingface", + "supabasecom": "supabase", "mongodbatlas": "mongodb", "atlas": "mongodb", + "elasticcloud": "elastic", "elasticsearch": "elastic", "grafanacloud": "grafana", + "sentryio": "sentry", "confluence": "atlassian", "atlassianaccount": "atlassian", + "jirasoftware": "jira", "bitbucketorg": "bitbucket", "pagerdutycom": "pagerduty", + "wpcom": "wordpress", "wordpresscom": "wordpress", "wordpressorg": "wordpress", + "namecheapcom": "namecheap", "godaddycom": "godaddy", "porkbuncom": "porkbun", + "netlifycom": "netlify", "vercelcom": "vercel", "renderc": "render", "railwayapp": "railway", + # Homelab + "hass": "homeassistant", "homeassistantio": "homeassistant", "nabucasa": "homeassistant", + "unifi": "ubiquiti", "ui": "ubiquiti", "unificonsole": "ubiquiti", "plextv": "plex", + "synologyaccount": "synology", "synologydsm": "synology", "dsm": "synology", + "nextcloudcom": "nextcloud", "unraidnet": "unraid", "limetech": "unraid", + "tailscalecom": "tailscale", + # Security + "protonme": "proton", "protonmailcom": "protonmail", "protonmail": "protonmail", + "protonpass": "proton", "protondrive": "proton", "tutanota": "tuta", "tutanotacom": "tuta", + "tutaio": "tuta", "1passwordcom": "1password", "onepassword": "1password", + "agilebits": "1password", "bitwardencom": "bitwarden", "vaultwarden": "bitwarden", + "lastpasscom": "lastpass", "keepersecurity": "keeper", "nord": "nordvpn", + "nordaccount": "nordvpn", "yubikey": "yubico", "oktacom": "okta", "auth0com": "auth0", + # Money + "coinbasecom": "coinbase", "coinbasepro": "coinbase", "binancecom": "binance", + "binanceus": "binance", "paypalcom": "paypal", "stripecom": "stripe", + "transferwise": "wise", "wisecom": "wise", "revolutcom": "revolut", + "amex": "americanexpress", "americanexpresscom": "americanexpress", + "jpmorganchase": "chase", "chasecom": "chase", "bofa": "bankofamerica", + "wellsfargocom": "wellsfargo", "cashapp": "cashapp", "squareup": "square", + "quickbooksonline": "quickbooks", "turbotax": "intuit", "mint": "intuit", + # Comms / social + "slackcom": "slack", "discordcom": "discord", "discordapp": "discord", + "zoomus": "zoom", "telegramorg": "telegram", "linkedincom": "linkedin", + "redditcom": "reddit", "twitchtv": "twitch", "tiktokcom": "tiktok", "snap": "snapchat", + "bsky": "bluesky", "blueskysocial": "bluesky", "mastodonsocial": "mastodon", + "signalorg": "signal", "wikimedia": "wikipedia", "wikimediafoundation": "wikipedia", + # Mail hosts (matched from the label's domain when the issuer is unknown) + "yahoocom": "yahoo", "ymail": "yahoo", "ymailcom": "yahoo", "aol": "yahoo", + "zohomail": "zoho", "zohocom": "zoho", "megaio": "mega", "meganz": "mega", + "backblazecom": "backblaze", "b2": "backblaze", + # Misc + "dropboxcom": "dropbox", "boxcom": "box", "notionso": "notion", "evernotecom": "evernote", + "figmacom": "figma", "spotifycom": "spotify", "netflixcom": "netflix", + "ubercom": "uber", "airbnbcom": "airbnb", "ebaycom": "ebay", "etsycom": "etsy", + "patreoncom": "patreon", "substackcom": "substack", "mediumcom": "medium", + "tumblrcom": "tumblr", "shopifycom": "shopify", "squarespacecom": "squarespace", + "wixcom": "wix", "mailchimpcom": "mailchimp", "hubspotcom": "hubspot", + "zendeskcom": "zendesk", "duolingocom": "duolingo", "courseraorg": "coursera", + "udemycom": "udemy", "ghostorg": "ghost", "hostingercom": "hostinger", + "airtablecom": "airtable", "asanacom": "asana", "clickupcom": "clickup", + "basecampcom": "basecamp", "linearapp": "linear", "todoistcom": "todoist", + "mirocom": "miro", "dribbblecom": "dribbble", "behancenet": "behance", + "vimeocom": "vimeo", "youtubecom": "youtube", "yt": "youtube", + "openseaio": "opensea", "trezorio": "trezor", "kucoincom": "kucoin", + "salesforcecom": "salesforce", "sfdc": "salesforce", "nvidiacom": "nvidia", + "geforce": "nvidia", "geforcenow": "nvidia", "samsungaccount": "samsung", + "sonyaccount": "sony", "ciscocom": "cisco", "meraki": "cisco", "intelcom": "intel", + "amdcom": "amd", + "squareenixaccount": "squareenix", "robloxcom": "roblox", "zapiercom": "zapier", + "iftttcom": "ifttt", "vultrcom": "vultr", "datadoghq": "datadog", "newreliccom": "newrelic", + "clickup": "clickup", "anthropiccom": "anthropic", "claudeai": "claude", +} + + +def norm(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "", s.lower().replace("&", "and")) + + +def fetch(url: str) -> str: + req = urllib.request.Request(url, headers={"User-Agent": "openmfa-fetch-icons/1.0"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode("utf-8") + + +def parse_svg(svg: str): + vb = re.search(r'viewBox="([^"]+)"', svg) + paths = re.findall(r']*\sd="([^"]+)"', svg) + if not vb or not paths: + raise ValueError("no viewBox/path") + return vb.group(1), paths + + +def main() -> int: + index = json.loads(fetch(f"{SI_CDN}/data/simple-icons.json")) + by_slug = {i["slug"]: i for i in (index["icons"] if isinstance(index, dict) else index)} + + missing = [s for s in SIMPLE if s not in by_slug] + if missing: + print("not in simple-icons:", " ".join(missing), file=sys.stderr) + return 1 + + jobs = [(s, "si") for s in SIMPLE] + [(name, "fa") for name, _, _ in FONT_AWESOME] + + def load(job): + name, src = job + url = f"{SI_CDN}/icons/{name}.svg" if src == "si" else f"{FA_CDN}/{name}.svg" + return job, parse_svg(fetch(url)) + + icons = {} + aliases = {} + with ThreadPoolExecutor(max_workers=12) as pool: + for (name, src), (vb, paths) in pool.map(load, jobs): + if src == "si": + meta = by_slug[name] + title, hexcol = meta["title"], "#" + meta["hex"] + for aka in (meta.get("aliases") or {}).get("aka", []): + aliases.setdefault(norm(aka), name) + else: + title, hexcol = next((t, c) for n, t, c in FONT_AWESOME if n == name) + icons[name] = [title, vb, paths, hexcol] + aliases.setdefault(norm(title), name) + + for k, v in ALIASES.items(): + if v not in icons: + print(f"alias {k!r} points at unknown icon {v!r}", file=sys.stderr) + return 1 + aliases[norm(k)] = v + # A key that is itself an icon needs no alias entry. + aliases = {k: v for k, v in sorted(aliases.items()) if k not in icons and k} + + body = ( + "// GENERATED by scripts/fetch-icons.py — do not edit by hand.\n" + f"// Simple Icons {SI_VERSION} (CC0-1.0) and Font Awesome Free {FA_VERSION} brands\n" + "// (CC BY 4.0). Brand marks are trademarks of their respective owners.\n" + "// Shape: slug -> [title, viewBox, [path d, ...], brand colour].\n" + "window.OPENMFA_ICONS = " + + json.dumps(dict(sorted(icons.items())), separators=(",", ":"), ensure_ascii=False) + + ";\n" + "window.OPENMFA_ICON_ALIASES = " + + json.dumps(aliases, separators=(",", ":"), ensure_ascii=False) + + ";\n" + ) + OUT.write_text(body) + print(f"wrote {OUT} — {len(icons)} icons, {len(aliases)} aliases, {len(body)//1024} KiB") + return 0 + + +if __name__ == "__main__": + sys.exit(main())