app passwords for WebDAV mounts
Settings → Security creates per-client credentials for the WebDAV mount. Each one is shown once, opens /dav only, and can be revoked on its own. The account password stays out of the mount, and an account that requires a passkey can be mounted at all: HTTP Basic carries a password and nothing else, so before this such an account had no way in. The secret is 16 random bytes, stored as a plain SHA-256. Unlike a user's password there is no dictionary to run against 128 random bits, so a KDF buys nothing, and a lookup by hash replaces a per-request verification. That is also what makes revocation immediate: the account-password path caches a verified Basic credential for five minutes, this path caches nothing. dav::authenticate tries the app password before the account password and ignores the Basic user name, the way the share mount already does — the secret names the account by itself. A lookup error is logged rather than swallowed, because falling through would 401 a valid credential and also count as a failed login for that name. last_used_at is written at most hourly. A mount client re-sends its credential on every request, and stamping each one would turn every read into a write. A self-service password change leaves app passwords standing, exactly as it leaves the account's passkeys standing; whoever changes it still holds both. An admin password reset deletes them together with the passkeys, because that path exists to lock a stranger out and must not leave a live mount behind. Schema v11: an app_passwords table, capped at ten rows per account, with the count and the insert in one transaction so parallel creates cannot exceed it. secret_hash is UNIQUE because the lookup keys on it alone. Also folds three copies of a hex encoder into auth::hex, the passkey and app-password label helpers into api::common::credential_label, and the test harness copies of basic() into tests/common. copy_to_clipboard moves from the shares view into web::util, taking its toast message as an argument.
MREADME.md
@@ -28,7 +28,8 @@ external services.
read-write folder. An admin setting turns writable shares on or off
globally, and a read-only folder cannot be shared writable. Deleting, renaming, or moving an
item revokes its shares. See [Shares](#shares).
- **WebDAV**: mount your folders, or a share, in a file manager. See
- **WebDAV**: mount your folders, or a share, in a file manager. Per-client
app passwords keep the account password out of the mount. See
[WebDAV](#webdav).
- **UI**: light, dark, or system theme. English, German, and French.
Optional single-click open.
@@ -155,12 +156,13 @@ Passkeys need a domain name. Set `--public-url` behind a proxy that rewrites
still works on such a host, but an account requiring both factors does not.
> [!NOTE]
> An account that requires both factors cannot use WebDAV, because HTTP Basic
> carries a password and nothing else.
> The account password is not what a mount should carry. HTTP Basic sends a
> password and nothing else, so an account that requires both factors cannot
> use it at all. Create an app password instead, under Settings → Security.
Admins recover a locked-out account by setting a new password for it. That
also deletes the account's passkeys and drops any two-factor requirement, so
the new password is the one way back in.
also deletes the account's passkeys and its app passwords, and drops any
two-factor requirement, so the new password is the one way back in.
## WebDAV
@@ -168,7 +170,7 @@ Two mount points. Your permissions are the same as in the web UI.
| URL | Contents | Credentials |
|-----|----------|-------------|
| `https://host/dav` | Every root folder the user has, one collection each | Account name and password (see [Sign-in](#sign-in)) |
| `https://host/dav` | Every root folder the user has, one collection each | Account name and an app password, or the account password (see below) |
| `https://host/dav-share/<token>` | One public share | None, or the share password |
Mount it with the file manager you already have:
@@ -181,8 +183,16 @@ sudo mount -t davfs https://host/dav /mnt/files
davs://host/dav
```
App passwords, under Settings → Security, are the credential to mount with.
One per client, shown once, good for WebDAV only. Revoke one from that list
and its mount stops on the next request. Changing your own password leaves
them working, the same way it leaves your passkeys alone; an admin reset
deletes them all.
Notes:
- The mount ignores the user name next to an app password: the secret already
says which account it belongs to.
- Under `/dav` each of your folders is one entry, named as it is in the web UI.
Two folders with the same name get a number added.
- A share of a single file cannot be mounted. Share a folder or open it in the web UI instead.
Mapi-types/src/lib.rs
@@ -28,6 +28,10 @@ pub const AUTH_PASSKEYS_REGISTER: &str = "/api/auth/passkeys/register";
/// Start a passkey sign-in (`POST`, no session needed). Finished at
/// `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
pub const AUTH_PASSKEY_LOGIN: &str = "/api/auth/passkey/login";
/// The signed-in user's WebDAV app passwords: `GET {AUTH_APP_PASSWORDS}`
/// lists them, `POST` creates one, `DELETE {AUTH_APP_PASSWORDS}/{id}` revokes
/// one.
pub const AUTH_APP_PASSWORDS: &str = "/api/auth/app-passwords";
/// Second leg of both WebAuthn ceremonies: the browser's answer goes to the
/// begin path plus this suffix.
pub const FINISH_SUFFIX: &str = "/finish";
@@ -626,6 +630,37 @@ pub struct PasskeyInfo {
pub discoverable: Option<bool>,
}
/// One app password, as shown in profile settings.
///
/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
/// which exists only in [`NewAppPassword`].
#[derive(Serialize, Deserialize, Clone)]
pub struct AppPasswordInfo {
pub id: i64,
/// User-chosen label ("Laptop mount", "phone").
pub name: String,
/// RFC 3339 UTC.
pub created_at: String,
/// Only tracked to the hour.
pub last_used_at: Option<String>,
}
/// `POST {AUTH_APP_PASSWORDS}`.
#[derive(Serialize, Deserialize)]
pub struct CreateAppPassword {
pub name: String,
}
/// The answer to `POST {AUTH_APP_PASSWORDS}`.
///
/// The only time `secret` is readable. The server keeps a hash of it.
#[derive(Serialize, Deserialize)]
pub struct NewAppPassword {
#[serde(flatten)]
pub info: AppPasswordInfo,
pub secret: String,
}
/// `POST {AUTH_PASSWORD}` — set or change the password.
///
/// No current password to confirm: a passkey-only account has none to give.
Aserver/src/api/app_passwords.rs
@@ -0,0 +1,87 @@
//! App passwords: per-client credentials for WebDAV mounts.
//!
//! One exists so a mount never carries the account password, and so an
//! account that requires a passkey in the browser can be mounted at all.
//! HTTP Basic can only send a password, and [`crate::api::dav`] refuses to
//! quietly downgrade that requirement.
//!
//! A self-service password change leaves app passwords standing, exactly as
//! it leaves the account's passkeys standing. Only an admin reset sweeps
//! them: that one exists to lock a stranger out. For the same reason these
//! routes do not drop the account's other sessions, which every route in
//! [`crate::api::passkeys`] does.
use std::sync::Arc;
use api_types::{AppPasswordInfo, CreateAppPassword, NewAppPassword, OkResp};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::{SessionUser, credential_label};
use crate::auth;
use crate::db::AppPasswordRow;
use crate::error::{ApiError, AppState};
fn info(row: AppPasswordRow) -> AppPasswordInfo {
AppPasswordInfo {
id: row.id,
name: row.name,
created_at: row.created_at,
last_used_at: row.last_used_at,
}
}
/// GET `{AUTH_APP_PASSWORDS}`.
pub async fn list(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
) -> Result<Json<Vec<AppPasswordInfo>>, ApiError> {
let rows = state.db.app_passwords(user.id).await?;
Ok(Json(rows.into_iter().map(info).collect()))
}
/// POST `{AUTH_APP_PASSWORDS}` — create one and return its secret.
pub async fn create(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
Json(req): Json<CreateAppPassword>,
) -> Result<Json<NewAppPassword>, ApiError> {
let secret = auth::app_password();
let row = state
.db
.add_app_password(
user.id,
&credential_label(&req.name, "App password"),
&auth::app_password_hash(&secret),
)
.await?;
let Some(row) = row else {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"this account already holds as many app passwords as it may",
"err_app_password_limit",
));
};
tracing::info!(user = %user.name, name = %row.name, "app password created");
Ok(Json(NewAppPassword {
info: info(row),
secret,
}))
}
/// DELETE `{AUTH_APP_PASSWORDS}/{id}`.
pub async fn delete(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.delete_app_password(id, user.id).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"no such app password",
"err_app_password_not_found",
));
}
Ok(Json(OkResp {}))
}
Mserver/src/api/common.rs
@@ -275,6 +275,15 @@ pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
})
}
/// A user-chosen label for a credential: trimmed, bounded, never empty.
pub(crate) fn credential_label(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
trimmed.chars().take(64).collect()
}
pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
if pw.len() < 8 {
return Err(ApiError::localized(
Mserver/src/api/dav.rs
@@ -4,7 +4,8 @@
//!
//! * `{DAV}` — a signed-in user's roots. Each root is a child collection of a
//! synthetic top-level directory, so one mount covers every root the user
//! has.
//! has. Basic takes either the account password or one of the account's app
//! passwords.
//! * `{DAV_SHARE}/{token}` — one public share, mounted at its own root.
//!
//! All filesystem access goes through [`crate::fs`], so a mount inherits the
@@ -271,6 +272,23 @@ async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String,
}
let (name, password) = auth::basic_credentials(headers)?;
// The Basic user name is ignored: the secret already names the account.
match state
.db
.user_by_app_password(&auth::app_password_hash(&password))
.await
{
Ok(Some(user)) => {
let roots = state.db.user_roots(user.id).await.ok()?;
return Some((user.name, roots));
}
Ok(None) => {}
// A lookup error 401s a valid app password and counts as a failed
// login for that name below. Nothing else records that.
Err(e) => tracing::warn!(error = %e, "app password lookup failed"),
}
let id = auth::verify_cached(0, &name, &password, || {
let (state, name, password) = (state, name.clone(), password.clone());
async move {
Mserver/src/api/mod.rs
@@ -1,9 +1,9 @@
use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE,
AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, DAV,
DAV_SHARE, FILES, FINISH_SUFFIX, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS, AUTH_LOGIN, AUTH_LOGOUT,
AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD,
AUTH_SETUP, DAV, DAV_SHARE, FILES, FINISH_SUFFIX, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -86,6 +86,7 @@ pub(crate) fn is_scriptable_mime(mime: &str) -> bool {
}
mod admin;
mod app_passwords;
mod auth;
pub(crate) mod common;
mod dav;
@@ -105,6 +106,7 @@ pub fn router(state: Arc<AppState>) -> Router {
let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
let passkey_id = format!("{AUTH_PASSKEYS}/{{id}}");
let app_password_id = format!("{AUTH_APP_PASSWORDS}/{{id}}");
let passkey_register_finish = format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}");
let passkey_login_finish = format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}");
let admin_share_id = format!("{ADMIN_SHARES}/{{id}}");
@@ -133,6 +135,11 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(&passkey_id, delete(passkeys::delete))
.route(AUTH_PASSKEY_LOGIN, post(passkeys::login_begin))
.route(&passkey_login_finish, post(passkeys::login_finish))
.route(
AUTH_APP_PASSWORDS,
get(app_passwords::list).post(app_passwords::create),
)
.route(&app_password_id, delete(app_passwords::delete))
.route(SEARCH, get(search::search))
.route(&files_root, get(files::list_root).put(files::file_put_root))
.route(&files_item, get(files::file_get))
Mserver/src/api/passkeys.rs
@@ -18,7 +18,7 @@ use axum::response::{IntoResponse, Response};
use webauthn_rs::prelude::*;
use webauthn_rs_proto::{AllowCredentials, ResidentKeyRequirement};
use crate::api::common::{SessionUser, hash_password, validate_password};
use crate::api::common::{SessionUser, credential_label, hash_password, validate_password};
use crate::auth::{self, parse_session_cookie, session_cookie};
use crate::db::{PASSKEY_LIMIT, PasskeyDeleted, PasskeyRow};
use crate::error::{ApiError, AppState};
@@ -102,14 +102,6 @@ async fn invalidate_elsewhere(
Ok(())
}
fn passkey_name(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "Passkey".to_string();
}
trimmed.chars().take(64).collect()
}
fn info(row: &PasskeyRow) -> PasskeyInfo {
PasskeyInfo {
id: row.id,
@@ -360,7 +352,7 @@ pub async fn register_finish(
user.id,
passkey.cred_id().as_ref(),
&encoded,
&passkey_name(&body.name),
&credential_label(&body.name, "Passkey"),
discoverable,
)
.await
Mserver/src/auth.rs
@@ -53,15 +53,35 @@ pub fn share_token() -> String {
hex_token(16)
}
/// The secret of an app password: 16 random bytes, hex-encoded.
pub fn app_password() -> String {
hex_token(16)
}
/// The stored form of an app password secret.
///
/// SHA-256, not Argon2: the secret is 128 random bits, so no dictionary
/// applies. A lookup by hash then replaces a per-request verification.
pub fn app_password_hash(secret: &str) -> String {
use sha2::{Digest, Sha256};
hex(&Sha256::digest(secret.as_bytes()))
}
fn hex_token(bytes: usize) -> String {
use rand::RngCore;
use std::fmt::Write as _;
let mut b = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut b);
b.iter().fold(String::with_capacity(bytes * 2), |mut s, x| {
let _ = write!(s, "{x:02x}");
s
})
hex(&b)
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
/// Failed logins per name within the last [`FAILURE_WINDOW`].
Mserver/src/db.rs
@@ -5,7 +5,7 @@ pub use api_types::{AuthMode, Mode};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 10;
const SCHEMA_VERSION: i64 = 11;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -87,6 +87,9 @@ pub const NO_PASSWORD: &str = "";
/// account can push past the padding.
pub const PASSKEY_LIMIT: usize = 8;
/// How many app passwords one account may hold. One per client is the point.
pub const APP_PASSWORD_LIMIT: usize = 10;
/// What [`Db::delete_passkey`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasskeyDeleted {
@@ -110,6 +113,15 @@ pub struct PasskeyRow {
pub passkey: String,
}
/// One app password, without its secret.
#[derive(Debug, Clone)]
pub struct AppPasswordRow {
pub id: i64,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RootRow {
pub id: i64,
@@ -312,6 +324,21 @@ impl Db {
ON users(webauthn_id) WHERE webauthn_id IS NOT NULL;",
)?;
}
if version < 11 {
// `secret_hash` is UNIQUE because the lookup keys on it.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS app_passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
secret_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user
ON app_passwords(user_id);",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -602,14 +629,14 @@ impl Db {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
// An admin sets a password to get someone back into a locked-out
// account, so the passkeys and the requirement to use one go with it.
// The account is left with exactly one way in, which is the one the
// admin just handed over. Doing it always rather than on request means
// a reset cannot half-happen: there is no way to set a password and
// leave a second factor the user no longer has.
// account, so every other way in goes with it: the passkeys, the app
// passwords, the requirement to use one. Always rather than on
// request, so a reset cannot leave a credential the user no longer
// has.
if let Some(h) = pass_hash {
set_password(&tx, id, h)?;
tx.execute("DELETE FROM passkeys WHERE user_id = ?1", [id])?;
tx.execute("DELETE FROM app_passwords WHERE user_id = ?1", [id])?;
tx.execute(
"UPDATE users SET auth_mode = ?1 WHERE id = ?2",
params![SqlAuthMode(AuthMode::Either), id],
@@ -861,6 +888,92 @@ impl Db {
Ok(())
}
// ---------- app passwords (WebDAV) ----------
pub async fn app_passwords(&self, user_id: i64) -> DbResult<Vec<AppPasswordRow>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT id, name, created_at, last_used_at
FROM app_passwords WHERE user_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([user_id], map_app_password)?;
rows.collect()
}
/// `None` means the account is already at [`APP_PASSWORD_LIMIT`]. Count
/// and insert share one transaction, so the cap cannot be raced.
pub async fn add_app_password(
&self,
user_id: i64,
name: &str,
secret_hash: &str,
) -> DbResult<Option<AppPasswordRow>> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let held: i64 = tx.query_row(
"SELECT COUNT(*) FROM app_passwords WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)?;
if held as usize >= APP_PASSWORD_LIMIT {
return Ok(None);
}
tx.execute(
"INSERT INTO app_passwords (user_id, name, secret_hash, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![user_id, name, secret_hash, now()],
)?;
let row = tx.query_row(
"SELECT id, name, created_at, last_used_at FROM app_passwords WHERE id = ?1",
[tx.last_insert_rowid()],
map_app_password,
)?;
tx.commit()?;
Ok(Some(row))
}
/// `false` means no row matched: an unknown id, or someone else's.
///
/// No reachability check, unlike [`Db::delete_passkey`]: an app password
/// never signs in to the web UI.
pub async fn delete_app_password(&self, id: i64, user_id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute(
"DELETE FROM app_passwords WHERE id = ?1 AND user_id = ?2",
params![id, user_id],
)? > 0)
}
/// The account an app password opens. Stamps `last_used_at` on the way.
///
/// Inactive accounts are excluded here, because nothing downstream in the
/// WebDAV path looks at the flag.
pub async fn user_by_app_password(&self, secret_hash: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
let user = c
.query_row(
&format!(
"SELECT {USER_COLS_U}
FROM app_passwords a
JOIN users u ON u.id = a.user_id
WHERE a.secret_hash = ?1 AND u.active = 1"
),
[secret_hash],
map_user,
)
.optional()?;
if user.is_some() {
// A mount re-sends its credential on every request. An hour's
// resolution keeps that from writing the stamp on each one.
c.execute(
"UPDATE app_passwords SET last_used_at = ?1
WHERE secret_hash = ?2 AND (last_used_at IS NULL OR last_used_at < ?3)",
params![now(), secret_hash, an_hour_ago()],
)?;
}
Ok(user)
}
/// Delete a user. `false` means no row matched.
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
@@ -1157,6 +1270,16 @@ fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
})
}
/// Column order matched by the `app_passwords` SELECTs above.
fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordRow> {
Ok(AppPasswordRow {
id: r.get(0)?,
name: r.get(1)?,
created_at: r.get(2)?,
last_used_at: r.get(3)?,
})
}
/// Column order matched by the two `shares` SELECTs above.
fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
Ok(ShareRow {
@@ -1188,6 +1311,13 @@ fn expiry_cutoff() -> String {
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
/// An hour back, in the same format as [`now`]. The format sorts
/// lexicographically, so SQL can compare the two as text.
fn an_hour_ago() -> String {
(chrono::Utc::now() - chrono::Duration::hours(1))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
@@ -1250,6 +1380,22 @@ mod tests {
db.update_user(id, pass, admin, active, None).await.unwrap();
}
/// A timestamp `d` in the past, in the format the stamps use.
fn ago(d: chrono::Duration) -> String {
(chrono::Utc::now() - d).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
/// Backdate a stamp, which production code has no reason to do.
async fn set_last_used(db: &Db, secret_hash: &str, at: &str) {
db.0.lock()
.await
.execute(
"UPDATE app_passwords SET last_used_at = ?1 WHERE secret_hash = ?2",
params![at, secret_hash],
)
.unwrap();
}
async fn db_with_admin() -> (Db, User) {
let db = mem().await;
let hash = crate::auth::hash_password("admin1234").unwrap();
@@ -1268,6 +1414,102 @@ mod tests {
assert!(db.all_users_with_roots().await.unwrap().is_empty());
}
#[tokio::test]
async fn app_passwords_open_one_account_and_stamp_their_use() {
let (db, admin) = db_with_admin().await;
let secret = crate::auth::app_password();
let hash = crate::auth::app_password_hash(&secret);
let row = db
.add_app_password(admin.id, "laptop", &hash)
.await
.unwrap()
.unwrap();
assert_eq!(row.name, "laptop");
assert!(row.last_used_at.is_none());
let hit = db.user_by_app_password(&hash).await.unwrap().unwrap();
assert_eq!(hit.id, admin.id);
let listed = |db: &Db| {
let db = db.clone();
async move {
db.app_passwords(admin.id).await.unwrap()[0]
.last_used_at
.clone()
}
};
assert!(listed(&db).await.is_some(), "first use must stamp the row");
// Ten minutes back is inside the hour window, and far enough from
// `now()` that a rewrite would show at second resolution.
let inside = ago(chrono::Duration::minutes(10));
set_last_used(&db, &hash, &inside).await;
db.user_by_app_password(&hash).await.unwrap().unwrap();
assert_eq!(
listed(&db).await.as_deref(),
Some(inside.as_str()),
"a second use inside the hour wrote the stamp again"
);
let outside = ago(chrono::Duration::hours(2));
set_last_used(&db, &hash, &outside).await;
db.user_by_app_password(&hash).await.unwrap().unwrap();
assert_ne!(
listed(&db).await.as_deref(),
Some(outside.as_str()),
"a use after the window left the stamp stale"
);
// The raw secret is not the key, and an inactive account does not match.
let other = crate::auth::app_password_hash(&crate::auth::app_password());
assert!(db.user_by_app_password(&other).await.unwrap().is_none());
assert!(db.user_by_app_password(&secret).await.unwrap().is_none());
edit(&db, admin.id, None, None, Some(false)).await;
assert!(db.user_by_app_password(&hash).await.unwrap().is_none());
edit(&db, admin.id, None, None, Some(true)).await;
assert!(db.user_by_app_password(&hash).await.unwrap().is_some());
for i in 1..APP_PASSWORD_LIMIT {
let h = crate::auth::app_password_hash(&crate::auth::app_password());
assert!(
db.add_app_password(admin.id, &format!("c{i}"), &h)
.await
.unwrap()
.is_some()
);
}
let h = crate::auth::app_password_hash(&crate::auth::app_password());
assert!(
db.add_app_password(admin.id, "one-too-many", &h)
.await
.unwrap()
.is_none()
);
assert!(db.delete_app_password(row.id, admin.id).await.unwrap());
assert!(!db.delete_app_password(row.id, admin.id).await.unwrap());
assert!(db.user_by_app_password(&hash).await.unwrap().is_none());
}
#[tokio::test]
async fn an_admin_password_reset_revokes_the_app_passwords() {
let (db, admin) = db_with_admin().await;
let hash = crate::auth::app_password_hash(&crate::auth::app_password());
db.add_app_password(admin.id, "mount", &hash)
.await
.unwrap()
.unwrap();
// An edit that sets no password leaves them alone.
edit(&db, admin.id, None, None, Some(true)).await;
assert_eq!(db.app_passwords(admin.id).await.unwrap().len(), 1);
// A reset is meant to revoke every way in that existed before it.
let fresh = crate::auth::hash_password("rescued12").unwrap();
edit(&db, admin.id, Some(&fresh), None, None).await;
assert!(db.app_passwords(admin.id).await.unwrap().is_empty());
assert!(db.user_by_app_password(&hash).await.unwrap().is_none());
}
#[tokio::test]
async fn v1_db_migrates_to_v2() {
let dir = tempfile::tempdir().unwrap();
Aserver/tests/api_app_passwords.rs
@@ -0,0 +1,232 @@
//! App passwords: the self-service routes, and what they do at the WebDAV
//! mount.
mod common;
use axum::http::{Method, StatusCode};
use common::*;
use serde_json::json;
const ROUTE: &str = "/api/auth/app-passwords";
/// A `PROPFIND` of the user mount, the way a mount client authenticates.
async fn propfind(env: &Env, name: &str, password: &str) -> Resp {
Client::new(env.app.clone())
.raw(
Method::from_bytes(b"PROPFIND").unwrap(),
"/dav",
&[("depth", "1"), ("authorization", &basic(name, password))],
Vec::new(),
)
.await
}
/// Create one and return its secret.
async fn create(c: &Client, name: &str) -> String {
let r = c.post_json(ROUTE, &json!({ "name": name })).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json()["secret"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn an_app_password_mounts_an_account_that_requires_a_passkey() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
let secret = create(&admin, "laptop").await;
let r = propfind(&env, "admin", &secret).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
// The secret names the account, so the Basic user name is not consulted.
let r = propfind(&env, "nobody", &secret).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
// The account password is deliberately not tried before the switch: a
// success would be cached for five minutes.
//
// `both` needs an existing passkey. Its contents never matter here.
env.state
.db
.add_passkey(id, b"cred-app-pw", "{}", "Test key", Some(true))
.await
.unwrap()
.unwrap();
assert!(
env.state
.db
.set_user_auth_mode(id, api_types::AuthMode::Both)
.await
.unwrap()
);
assert_eq!(
propfind(&env, "admin", "admin1234").await.status,
StatusCode::UNAUTHORIZED
);
let r = propfind(&env, "admin", &secret).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
}
/// Pins the claim the UI makes: an app password signs in to WebDAV only.
#[tokio::test]
async fn an_app_password_opens_webdav_and_nothing_else() {
let env = Env::new().await;
let admin = env.admin().await;
let secret = create(&admin, "laptop").await;
let anon = Client::new(env.app.clone());
for name in ["admin", "nobody"] {
let r = anon
.post_json(
"/api/auth/login",
&json!({ "name": name, "password": secret }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "login as {name}");
}
// The JSON API accepts no Basic at all.
let r = anon
.raw(
Method::GET,
"/api/files/1",
&[("authorization", &basic("admin", &secret))],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text());
// A control: the same secret does open the mount.
assert_eq!(
propfind(&env, "admin", &secret).await.status,
StatusCode::MULTI_STATUS
);
}
#[tokio::test]
async fn revoking_one_closes_the_mount_immediately() {
let env = Env::new().await;
let admin = env.admin().await;
let secret = create(&admin, "laptop").await;
let id = admin.get(ROUTE).await.json()[0]["id"].as_i64().unwrap();
assert_eq!(
propfind(&env, "admin", &secret).await.status,
StatusCode::MULTI_STATUS
);
let r = admin.delete(&format!("{ROUTE}/{id}")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// No verified-credential cache to age out, unlike the account password.
assert_eq!(
propfind(&env, "admin", &secret).await.status,
StatusCode::UNAUTHORIZED
);
let r = admin.delete(&format!("{ROUTE}/{id}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(r.json()["code"], "err_app_password_not_found");
assert_eq!(
propfind(&env, "admin", "deadbeef").await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn the_list_never_shows_a_secret_and_the_count_is_capped() {
let env = Env::new().await;
let admin = env.admin().await;
create(&admin, " laptop ").await;
let listed = admin.get(ROUTE).await.json();
assert_eq!(listed[0]["name"], "laptop", "the label is trimmed");
assert_eq!(listed[0]["last_used_at"], json!(null));
assert!(
listed[0].get("secret").is_none(),
"the secret is only in the creating response: {listed}"
);
let r = admin.post_json(ROUTE, &json!({ "name": "" })).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["name"], "App password");
let r = admin
.post_json(ROUTE, &json!({ "name": "\u{1f511}".repeat(80) }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
r.json()["name"].as_str().unwrap().chars().count(),
64,
"the bound is on characters, not bytes"
);
// Three exist already: the named one, the empty label, the long one.
for i in 4..=server::db::APP_PASSWORD_LIMIT {
create(&admin, &format!("client {i}")).await;
}
let r = admin.post_json(ROUTE, &json!({ "name": "eleven" })).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_app_password_limit");
}
#[tokio::test]
async fn app_passwords_are_private_to_their_account() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass12").await;
let secret = create(&bob, "bobs laptop").await;
let bob_pw_id = bob.get(ROUTE).await.json()[0]["id"].as_i64().unwrap();
// An admin sees none of these and cannot delete one: the admin route is
// a password reset, not a credential browser.
assert_eq!(admin.get(ROUTE).await.json(), json!([]));
let r = admin.delete(&format!("{ROUTE}/{bob_pw_id}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(
propfind(&env, "bob", &secret).await.status,
StatusCode::MULTI_STATUS
);
// Signing out is not revoking: a mount keeps working across sessions.
assert_eq!(
bob.post_json("/api/auth/logout", &json!({})).await.status,
StatusCode::OK
);
assert_eq!(
propfind(&env, "bob", &secret).await.status,
StatusCode::MULTI_STATUS
);
// An admin password reset does revoke it.
let bob_id = user_id(&admin, "bob").await;
let r = admin
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "password": "rescued12" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
propfind(&env, "bob", &secret).await.status,
StatusCode::UNAUTHORIZED
);
let bob = login(&env, "bob", "rescued12").await;
assert_eq!(bob.get(ROUTE).await.json(), json!([]));
}
#[tokio::test]
async fn the_routes_need_a_session() {
let env = Env::new().await;
let _ = env.admin().await;
let anon = Client::new(env.app.clone());
assert_eq!(anon.get(ROUTE).await.status, StatusCode::UNAUTHORIZED);
assert_eq!(
anon.post_json(ROUTE, &json!({ "name": "x" })).await.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
anon.delete(&format!("{ROUTE}/1")).await.status,
StatusCode::UNAUTHORIZED
);
}
Mserver/tests/api_dav.rs
@@ -4,15 +4,9 @@
mod common;
use axum::http::{Method, StatusCode};
use base64::Engine as _;
use common::*;
use serde_json::json;
fn basic(name: &str, password: &str) -> String {
let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
format!("Basic {raw}")
}
fn method(name: &str) -> Method {
Method::from_bytes(name.as_bytes()).unwrap()
}
Mserver/tests/api_passkeys.rs
@@ -9,7 +9,6 @@
mod common;
use axum::http::{Method, StatusCode};
use base64::Engine as _;
use common::*;
use serde_json::json;
@@ -39,11 +38,6 @@ async fn give_passkey(env: &Env, user_id: i64, label: &[u8]) {
.expect("the account was already at the passkey limit");
}
fn basic(name: &str, password: &str) -> String {
let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
format!("Basic {raw}")
}
// ---------------------------------------------------------------------------
// Password
// ---------------------------------------------------------------------------
Mserver/tests/common/mod.rs
@@ -292,6 +292,13 @@ pub fn session_cookie(r: &Resp) -> Option<String> {
None
}
/// An `Authorization: Basic` header value.
pub fn basic(name: &str, password: &str) -> String {
use base64::Engine as _;
let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
format!("Basic {raw}")
}
/// POST /api/admin/users helper (used by several test files).
pub async fn create_user(
admin: &Client,
Mweb/src/api.rs
@@ -14,17 +14,18 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_EXISTS, ACTION_MKDIR,
ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER,
AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateShare, CreateUser, Credentials, ExistsReq,
ExistsResp, FILES, FINISH_SUFFIX, LoginReq, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH,
P_Q, P_ROOT, P_SCOPE, P_SHARE, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish,
Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, UnlockShare,
UpdateUser,
ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS,
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateAppPassword,
CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq,
Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE,
PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish, Root, SEARCH, SHARE,
SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, UnlockShare, UpdateUser,
};
pub use api_types::{
AdminShare, AdminUser, AuthMode, Entry, Existing, FilesResp, LoginResp, Me, Mode, OkResp, Op,
PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo, SaveResp, ShareInfo, UserInfo,
AdminShare, AdminUser, AppPasswordInfo, AuthMode, Entry, Existing, FilesResp, LoginResp, Me,
Mode, NewAppPassword, OkResp, Op, PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo,
SaveResp, ShareInfo, UserInfo,
};
#[derive(Debug, thiserror::Error)]
@@ -182,6 +183,25 @@ pub fn delete_passkey(id: i64) -> impl std::future::Future<Output = Result<OkRes
request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>)
}
pub fn list_app_passwords()
-> impl std::future::Future<Output = Result<Vec<AppPasswordInfo>, ApiError>> {
request("GET", AUTH_APP_PASSWORDS.to_string(), None::<()>)
}
pub fn create_app_password(
name: String,
) -> impl std::future::Future<Output = Result<NewAppPassword, ApiError>> {
request(
"POST",
AUTH_APP_PASSWORDS.to_string(),
Some(CreateAppPassword { name }),
)
}
pub fn delete_app_password(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{AUTH_APP_PASSWORDS}/{id}"), None::<()>)
}
/// Register a passkey end to end: ask for a challenge, hand it to the
/// browser, send the answer back.
///
Mweb/src/i18n.rs
@@ -184,6 +184,7 @@ macro_rules! i18n_keys {
i18n_keys! {
ACTIVE_CAN_SIGNIN = "active_can_signin" => "Active (can sign in)",
ADD_APP_PASSWORD = "add_app_password" => "Create app password",
ADD_FOLDER = "add_folder" => "Add folder…",
ADD_HERE = "add_here" => "Add this folder",
ADD_PASSKEY = "add_passkey" => "Add passkey",
@@ -194,6 +195,11 @@ i18n_keys! {
ALLOW_RW_SHARES = "allow_rw_shares" => "Allow writable shares",
ALLOW_RW_SHARES_DESC = "allow_rw_shares_desc" => "Let users create read-write share links. Off by default.",
AND_MORE = "and_more" => "…and {} more",
APP_PASSWORD_LABEL = "app_password_label" => "Name for this app password",
APP_PASSWORD_REMOVED = "app_password_removed" => "App password revoked",
APP_PASSWORD_SECRET_ONCE = "app_password_secret_once" => "Copy it now. It is never shown again.",
APP_PASSWORDS = "app_passwords" => "App passwords",
APP_PASSWORDS_HINT = "app_passwords_hint" => "An app password signs in to WebDAV only, never to this page. Use one per client instead of your account password.",
CANCEL = "cancel" => "Cancel",
CANT_DELETE_SELF = "cant_delete_self" => "You cannot delete yourself",
CLOSE = "close" => "Close",
@@ -243,6 +249,8 @@ i18n_keys! {
EMPTY_FOLDER = "empty_folder" => "This folder is empty.",
ERR_ADMIN_ONLY = "err_admin_only" => "admin only",
ERR_ALREADY_SET_UP = "err_already_set_up" => "server is already set up",
ERR_APP_PASSWORD_LIMIT = "err_app_password_limit" => "This account already has as many app passwords as it may.",
ERR_APP_PASSWORD_NOT_FOUND = "err_app_password_not_found" => "No such app password.",
ERR_BAD_ACTION = "err_bad_action" => "expected action=content",
ERR_BAD_BODY = "err_bad_body" => "invalid request body",
ERR_BAD_EXPIRES_AT = "err_bad_expires_at" => "the expiry date is invalid",
@@ -384,6 +392,7 @@ i18n_keys! {
NEW_PASSWORD_HINT = "new_password_hint" => "Leave empty to keep your current password.",
NEW_PASSWORD_KEEP = "new_password_keep" => "New password (leave blank to keep)",
NEW_USER = "new_user" => "New user",
NO_APP_PASSWORDS = "no_app_passwords" => "No app passwords.",
NO_FOLDERS_MSG = "no_folders_msg" => "An administrator hasn't assigned you any folders yet.",
NO_FOLDERS_TITLE = "no_folders_title" => "No folders available",
NO_PASSKEYS = "no_passkeys" => "No passkeys yet.",
@@ -565,6 +574,7 @@ i18n_keys! {
/// German translations.
const DE: &[(&str, &str)] = &[
("active_can_signin", "Aktiv (kann sich anmelden)"),
("add_app_password", "App-Passwort erstellen"),
("add_folder", "Ordner hinzufügen…"),
("add_here", "Diesen Ordner hinzufügen"),
("add_passkey", "Passkey hinzufügen"),
@@ -581,6 +591,17 @@ const DE: &[(&str, &str)] = &[
"Benutzern erlauben, schreibbare Freigabelinks zu erstellen. Standardmäßig aus.",
),
("and_more", "…und {} weitere"),
("app_password_label", "Name für dieses App-Passwort"),
("app_password_removed", "App-Passwort widerrufen"),
(
"app_password_secret_once",
"Jetzt kopieren. Es wird nie wieder angezeigt.",
),
("app_passwords", "App-Passwörter"),
(
"app_passwords_hint",
"Ein App-Passwort gilt nur für WebDAV, nicht für diese Seite. Nutze pro Client eines statt deines Kontopassworts.",
),
("cancel", "Abbrechen"),
("cant_delete_self", "Sie können sich nicht selbst löschen"),
("close", "Schließen"),
@@ -660,6 +681,11 @@ const DE: &[(&str, &str)] = &[
("empty_folder", "Dieser Ordner ist leer."),
(k::ERR_ADMIN_ONLY, "nur für Administratoren"),
(k::ERR_ALREADY_SET_UP, "Server ist bereits eingerichtet"),
(
k::ERR_APP_PASSWORD_LIMIT,
"Dieses Konto hat bereits so viele App-Passwörter, wie es haben darf.",
),
(k::ERR_APP_PASSWORD_NOT_FOUND, "Kein solches App-Passwort."),
(k::ERR_BAD_ACTION, "action=content erwartet"),
(k::ERR_BAD_BODY, "ungültiger Anfrageinhalt"),
(k::ERR_BAD_EXPIRES_AT, "das Ablaufdatum ist ungültig"),
@@ -936,6 +962,7 @@ const DE: &[(&str, &str)] = &[
"Neues Passwort (leer lassen, um beizubehalten)",
),
("new_user", "Neuer Benutzer"),
("no_app_passwords", "Keine App-Passwörter."),
(
"no_folders_msg",
"Ein Administrator hat Ihnen noch keine Ordner zugewiesen.",
@@ -1228,6 +1255,7 @@ const DE: &[(&str, &str)] = &[
/// French translations.
const FR: &[(&str, &str)] = &[
("active_can_signin", "Actif (peut se connecter)"),
("add_app_password", "Créer un mot de passe d'application"),
("add_folder", "Ajouter un dossier…"),
("add_here", "Ajouter ce dossier"),
("add_passkey", "Ajouter une clé d'accès"),
@@ -1247,6 +1275,17 @@ const FR: &[(&str, &str)] = &[
"Autoriser les utilisateurs à créer des liens de partage en lecture-écriture. Désactivé par défaut.",
),
("and_more", "…et {} autres"),
("app_password_label", "Nom de ce mot de passe d'application"),
("app_password_removed", "Mot de passe d'application révoqué"),
(
"app_password_secret_once",
"Copiez-le maintenant. Il ne sera plus jamais affiché.",
),
("app_passwords", "Mots de passe d'application"),
(
"app_passwords_hint",
"Un mot de passe d'application ne sert qu'au WebDAV, jamais à cette page. Utilisez-en un par client au lieu de votre mot de passe de compte.",
),
("cancel", "Annuler"),
(
"cant_delete_self",
@@ -1329,6 +1368,14 @@ const FR: &[(&str, &str)] = &[
("empty_folder", "Ce dossier est vide."),
(k::ERR_ADMIN_ONLY, "réservé aux administrateurs"),
(k::ERR_ALREADY_SET_UP, "le serveur est déjà configuré"),
(
k::ERR_APP_PASSWORD_LIMIT,
"Ce compte possède déjà le nombre maximal de mots de passe d'application.",
),
(
k::ERR_APP_PASSWORD_NOT_FOUND,
"Aucun mot de passe d'application correspondant.",
),
(k::ERR_BAD_ACTION, "action=content attendu"),
(k::ERR_BAD_BODY, "corps de requête invalide"),
(k::ERR_BAD_EXPIRES_AT, "la date d'expiration est invalide"),
@@ -1596,6 +1643,7 @@ const FR: &[(&str, &str)] = &[
"Nouveau mot de passe (laisser vide pour conserver)",
),
("new_user", "Nouvel utilisateur"),
("no_app_passwords", "Aucun mot de passe d'application."),
(
"no_folders_msg",
"Aucun administrateur ne vous a encore attribué de dossier.",
Mweb/src/util.rs
@@ -4,6 +4,25 @@ use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
/// Write `text` to the clipboard and confirm it with `msg`.
///
/// A rejected write is ignored, so the toast appears even when it failed.
pub fn copy_to_clipboard(text: &str, msg: &str, toast: ToastMsg) {
let Some(win) = web_sys::window() else {
show_error(toast, i18n::t(i18n::k::COPY_MANUAL).to_string());
return;
};
let cb = win.navigator().clipboard();
let (text, msg) = (text.to_string(), msg.to_string());
wasm_bindgen_futures::spawn_local(async move {
let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&text)).await;
show(toast, msg);
});
}
/// A window event listener that is removed when the current owner is disposed.
///
/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
Mweb/src/views/security.rs
@@ -1,5 +1,5 @@
//! The Security tab of profile settings: the password, the passkeys, and
//! which of the two this account needs to sign in.
//! The Security tab of profile settings: the password, the passkeys, which of
//! the two this account needs to sign in, and the app passwords for WebDAV.
//!
//! Every rule that keeps an account reachable lives on the server. This view
//! only hides controls that cannot work — a browser without WebAuthn, a
@@ -10,12 +10,12 @@
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AuthMode, Me, PasskeyInfo, input_value};
use crate::api::{self, AppPasswordInfo, AuthMode, Me, PasskeyInfo, input_value};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
use crate::util::select_value;
use crate::util::{copy_to_clipboard, select_input, select_value};
/// Trim an RFC 3339 timestamp to its date. The exact minute a passkey was
/// registered is noise in a list.
@@ -171,6 +171,61 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
});
});
// --- app passwords ----------------------------------------------------
let (app_passwords, set_app_passwords) = signal(Option::<Vec<AppPasswordInfo>>::None);
// The server cannot show a secret twice, so it lives only in this signal.
let (secret, set_secret) = signal(Option::<String>::None);
let reload_app_passwords = move || {
spawn_local(async move {
match api::list_app_passwords().await {
Ok(list) => set_app_passwords.set(Some(list)),
Err(e) => set_app_passwords.set({
show_error(toast, e.to_string());
Some(Vec::new())
}),
}
});
};
Effect::new(move |_| reload_app_passwords());
let create_app_password = move |_| {
if busy.get() {
return;
}
set_error.set(None);
// Clear the previous secret before the request, not after: two
// look-alike hex boxes invite copying the stale one.
set_secret.set(None);
let label = input_value("sec-app-name");
set_busy.set(true);
spawn_local(async move {
match api::create_app_password(label).await {
Ok(created) => {
clear_fields(&["sec-app-name"]);
// No toast: the secret box appearing below says it landed.
set_secret.set(Some(created.secret));
reload_app_passwords();
}
Err(e) => set_error.set(Some(e.to_string())),
}
set_busy.set(false);
});
};
let remove_app_password = Callback::new(move |id: i64| {
set_error.set(None);
spawn_local(async move {
match api::delete_app_password(id).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::APP_PASSWORD_REMOVED).to_string());
reload_app_passwords();
}
Err(e) => set_error.set(Some(e.to_string())),
}
});
});
let supported = crate::passkey::supported();
view! {
@@ -286,6 +341,102 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
</div>
</div>
</Show>
<h3 class="setting-label">{i18n::tr(i18n::k::APP_PASSWORDS)}</h3>
<p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORDS_HINT)}</p>
<div class="passkey-list">
{move || match app_passwords.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
.into_view()
.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::NO_APP_PASSWORDS)}</p> }
.into_view()
.into_any()
}
Some(list) => list
.into_iter()
.map(|p| {
view! { <AppPasswordRow info=p on_remove=remove_app_password/> }
})
.collect::<Vec<_>>()
.into_view()
.into_any(),
}}
</div>
{move || {
let Some(value) = secret.get() else {
return view! {}.into_any();
};
let copy = value.clone();
view! {
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
value=value
on:click=|ev| select_input(&ev)
/>
<button
class="btn"
on:click=move |_| copy_to_clipboard(
©,
i18n::t(i18n::k::COPIED),
toast,
)
>
<Icon name=IconName::Copy class="ic-btn".to_string()/>
{i18n::tr(i18n::k::COPY)}
</button>
</div>
<p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}</p>
}
.into_view()
.into_any()
}}
<div class="security-form">
<label class="field">
<span>{i18n::tr(i18n::k::APP_PASSWORD_LABEL)}</span>
<input id="sec-app-name" type="text" autocomplete="off"/>
</label>
<div class="modal-actions">
<button class="btn" disabled=move || busy.get() on:click=create_app_password>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{i18n::tr(i18n::k::ADD_APP_PASSWORD)}
</button>
</div>
</div>
</div>
}
}
/// One app password in the list. No warning row, unlike a passkey: nothing
/// about one can be half-working.
#[component]
fn AppPasswordRow(info: AppPasswordInfo, on_remove: Callback<i64>) -> impl IntoView {
let id = info.id;
let used = match &info.last_used_at {
Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
};
view! {
<div class="passkey-row">
<Icon name=IconName::Share class="ic-row".to_string()/>
<span class="passkey-main">
<span class="passkey-name">{info.name.clone()}</span>
<span class="setting-desc">
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))}
" · " {used}
</span>
</span>
<button
class="icon-btn"
title=i18n::t(i18n::k::DELETE)
aria-label=i18n::t(i18n::k::DELETE)
on:click=move |_| on_remove.run(id)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
}