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("