Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
555 changes: 543 additions & 12 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ from the system's file picker or from files dragged onto its window, streaming e
rather than loading it into the window first. A dropped folder is refused with a note to sync it
instead.

Signing in from the desktop app mints an app password named after the computer and keeps it in the
system keychain (Keychain on macOS, Credential Manager on Windows, the Secret Service on Linux), so
the app opens signed in and a folder keeps syncing past the twelve hours a session lasts. Signing
out revokes that app password on the server and removes it from the keychain. Revoking it from the
web app signs the desktop out on its next request. Without a keychain, as on some minimal Linux
desktops, the app says so and stays signed in until it closes.

## Layout

```
Expand Down Expand Up @@ -239,6 +246,7 @@ GET /v1/thumbnails/{*path} a thumbnail of an image, made on demand
GET /v1/app-passwords the credentials this account has minted
POST /v1/app-passwords mint one, shown once
DELETE /v1/app-passwords/{id} revoke one, taking effect immediately
DELETE /v1/app-passwords/current the one this request presents, to sign out
PUT /v1/auth/password change your own, giving the current one
GET /v1/users every account, with what each is using (admin)
POST /v1/users create one (admin)
Expand Down Expand Up @@ -284,6 +292,8 @@ POST /v1/version/{id}/{*path} restore it, keeping what it replaces as a ver
Every `/v1` route except login and `/v1/public/*` takes `Authorization: Bearer <session token>`.
The file, folder, trash, version and sharing routes also take an app password over Basic auth, the
credential a long-running sync keeps, since a session token lapses after `SESSION_TTL_SECONDS`.
`DELETE /v1/app-passwords/current` revokes the app password that authenticates the request, which is
how a client signs out without holding a session.

Deleting is reversible. `DELETE /v1/files/{*path}` marks the node and everything under it, credits
the quota and leaves the bytes alone, so `GET /v1/trash` lists what was deleted and a restore puts it
Expand Down
4 changes: 2 additions & 2 deletions api/src/app_passwords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub async fn revoke(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<(), ApiErr
/// Answers the account a `WebDAV` client is entitled to act as, or nothing at all. The presented
/// secret is high entropy and server-generated, so it is fingerprinted rather than run through a
/// password hash: clients send it on every request, and no dictionary reaches it.
pub async fn authenticate(pool: &PgPool, email: &Email, presented: &str) -> Option<User> {
pub async fn authenticate(pool: &PgPool, email: &Email, presented: &str) -> Option<(User, Uuid)> {
let user = crate::users::by_email(pool, email).await.ok().flatten()?;
if user.disabled_at.is_some() {
return None;
Expand All @@ -94,7 +94,7 @@ pub async fn authenticate(pool: &PgPool, email: &Email, presented: &str) -> Opti
.execute(pool)
.await;

Some(user)
Some((user, id))
}

fn secret() -> String {
Expand Down
6 changes: 3 additions & 3 deletions api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl Sessions {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Credential {
Session,
AppPassword,
AppPassword(Uuid),
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -186,10 +186,10 @@ impl FromRequestParts<AppState> for Caller {
(user, Credential::Session)
} else {
let (email, secret) = basic_credentials(parts).ok_or(ApiError::Unauthenticated)?;
let user = app_passwords::authenticate(&state.db, &email, &secret)
let (user, id) = app_passwords::authenticate(&state.db, &email, &secret)
.await
.ok_or(ApiError::Unauthenticated)?;
(user, Credential::AppPassword)
(user, Credential::AppPassword(id))
};

if !user.is_active() {
Expand Down
2 changes: 1 addition & 1 deletion api/src/dav/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ impl FromRequestParts<AppState> for DavCaller {

app_passwords::authenticate(&state.db, &email, &secret)
.await
.map(Self)
.map(|(user, _)| Self(user))
.ok_or(Unauthenticated)
}
}
Expand Down
3 changes: 3 additions & 0 deletions api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub enum ApiError {
Forbidden,
#[error("an app password cannot manage the account, sign in with the account's password")]
SessionRequired,
#[error("only an app password can revoke itself, a session revokes one by its id")]
AppPasswordRequired,
#[error("{0} already exists")]
Conflict(String),
#[error("a directory cannot be moved inside itself")]
Expand Down Expand Up @@ -96,6 +98,7 @@ impl ApiError {
| Self::OffsetMismatch { .. } => StatusCode::CONFLICT,
Self::Locked(_) => StatusCode::LOCKED,
Self::Incomplete { .. }
| Self::AppPasswordRequired
| Self::InvalidPath(_)
| Self::InvalidEmail(_)
| Self::WrongKind { .. }
Expand Down
13 changes: 12 additions & 1 deletion api/src/routes/app_passwords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde::Deserialize;
use uuid::Uuid;

use crate::app_passwords::{self, AppPassword, Minted};
use crate::auth::SessionCaller;
use crate::auth::{Caller, Credential, SessionCaller};
use crate::error::ApiError;
use crate::state::AppState;
use crate::users;
Expand Down Expand Up @@ -53,3 +53,14 @@ pub async fn revoke(
app_passwords::revoke(&state.db, caller.user_id(), id).await?;
Ok(StatusCode::NO_CONTENT)
}

pub async fn revoke_presented(
State(state): State<AppState>,
caller: Caller,
) -> Result<StatusCode, ApiError> {
let Credential::AppPassword(id) = caller.via else {
return Err(ApiError::AppPasswordRequired);
};
app_passwords::revoke(&state.db, caller.user.id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
4 changes: 4 additions & 0 deletions api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ pub fn router(state: AppState) -> Router {
"/v1/app-passwords",
get(app_passwords::list).post(app_passwords::mint),
)
.route(
"/v1/app-passwords/current",
delete(app_passwords::revoke_presented),
)
.route("/v1/app-passwords/{id}", delete(app_passwords::revoke))
.route("/v1/auth/password", put(users::change_password))
.route("/v1/users", get(users::list).post(users::create))
Expand Down
2 changes: 1 addition & 1 deletion api/tests/app_passwords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ database_test!(a_minted_secret_authenticates_its_owner, harness, {
let authenticated = authenticate(&harness.state.db, &email("dav@example.com"), &secret).await;

assert_eq!(
authenticated.map(|user| user.id),
authenticated.map(|(user, _)| user.id),
Some(owner.id),
"the credential a client stores in plain text is not the account password, but it is the account"
);
Expand Down
62 changes: 62 additions & 0 deletions api/tests/client_credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,65 @@ database_test!(a_session_still_manages_the_account, harness, {
StatusCode::CREATED
);
});

database_test!(an_app_password_revokes_itself, harness, {
let minted = app_password(&harness, "leaving@example.com", Role::Member).await;

assert_eq!(
status(
&harness,
"DELETE",
"/v1/app-passwords/current",
&minted.basic,
""
)
.await,
StatusCode::NO_CONTENT
);
assert_eq!(
status(&harness, "GET", "/v1/folders", &minted.basic, "").await,
StatusCode::UNAUTHORIZED,
"signing out leaves nothing behind that still opens the files"
);
});

database_test!(revoking_itself_leaves_the_other_app_passwords, harness, {
let leaving = app_password(&harness, "two@example.com", Role::Member).await;
let mut tx = harness.state.db.begin().await.expect("begin");
let staying = mint(&mut tx, leaving.owner, "the other machine")
.await
.expect("minting");
tx.commit().await.expect("commit");
let staying = basic("two@example.com", &staying.secret);

status(
&harness,
"DELETE",
"/v1/app-passwords/current",
&leaving.basic,
"",
)
.await;

assert_eq!(
status(&harness, "GET", "/v1/folders", &staying, "").await,
StatusCode::OK
);
});

database_test!(a_session_has_no_current_app_password_to_revoke, harness, {
let owner = harness.account("bearer@example.com", Role::Member).await;
let token = harness.state.sessions.issue(owner.id).expect("a token");

assert_eq!(
status(
&harness,
"DELETE",
"/v1/app-passwords/current",
&format!("Bearer {token}"),
""
)
.await,
StatusCode::BAD_REQUEST
);
});
2 changes: 2 additions & 0 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
keyring = "4"
gethostname = "1"

[lints]
workspace = true
56 changes: 56 additions & 0 deletions app/src/keychain.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use keyring::{Entry, Error};
use serde::{Deserialize, Serialize};

const SERVICE: &str = "Stashden";
const ACCOUNT: &str = "desktop";

#[derive(Clone, Serialize, Deserialize)]
pub struct Credentials {
pub server: String,
pub email: String,
pub secret: String,
}

fn entry() -> Result<Entry, String> {
Entry::new(SERVICE, ACCOUNT)
.map_err(|error| format!("the system keychain is unavailable: {error}"))
}

pub async fn keep(credentials: Credentials) -> Result<(), String> {
blocking(move || {
let encoded = serde_json::to_string(&credentials).map_err(|error| error.to_string())?;
entry()?
.set_password(&encoded)
.map_err(|error| format!("the system keychain refused the app password: {error}"))
})
.await
}

pub async fn kept() -> Result<Option<Credentials>, String> {
blocking(|| match entry()?.get_password() {
Ok(encoded) => serde_json::from_str(&encoded)
.map(Some)
.map_err(|error| format!("the keychain entry is not one this app wrote: {error}")),
Err(Error::NoEntry) => Ok(None),
Err(error) => Err(format!("the system keychain could not be read: {error}")),
})
.await
}

pub async fn forget() -> Result<(), String> {
blocking(|| match entry()?.delete_credential() {
Ok(()) | Err(Error::NoEntry) => Ok(()),
Err(error) => Err(format!(
"the system keychain kept the app password: {error}"
)),
})
.await
}

async fn blocking<T: Send + 'static>(
work: impl FnOnce() -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
tokio::task::spawn_blocking(work)
.await
.map_err(|error| error.to_string())?
}
77 changes: 62 additions & 15 deletions app/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
mod failure;
mod keychain;
mod sync;

use std::path::PathBuf;

use stashden_client::sync::watch::Session as SyncSession;
use stashden_client::{Remote, free_path};
use stashden_client::{Remote, RemoteError, free_path};
use stashden_core::grant::{Given, NewGrant, Received};
use stashden_core::node::{Node, Trashed};
use stashden_core::share::{Minted, NewShare, Share};
Expand All @@ -16,14 +17,9 @@ use tauri_plugin_updater::UpdaterExt;
use tokio::sync::Mutex;

use failure::Failure;
use keychain::Credentials;
use uuid::Uuid;

#[derive(Clone)]
struct Credentials {
server: String,
token: String,
}

#[derive(Default)]
struct Desktop {
remote: Mutex<Option<Remote>>,
Expand All @@ -39,19 +35,60 @@ async fn login(
server: String,
email: String,
password: String,
) -> Result<(), String> {
let (remote, session) = Remote::login(&server, &email, &password)
) -> Result<Option<String>, String> {
let (session, _) = Remote::login(&server, &email, &password)
.await
.map_err(|error| error.to_string())?;
let minted = session
.mint_app_password(&format!("Stashden desktop on {}", this_computer()))
.await
.map_err(|error| error.to_string())?;

*desktop.remote.lock().await = Some(remote);
*desktop.credentials.lock().await = Some(Credentials {
let credentials = Credentials {
server,
token: session.token,
});
email,
secret: minted.secret,
};
connect(&desktop, credentials.clone()).await?;
Ok(keychain::keep(credentials)
.await
.err()
.map(|reason| format!("Signed in until the app closes, then it will ask again: {reason}")))
}

#[tauri::command]
async fn resume(desktop: State<'_, Desktop>) -> Result<bool, String> {
let Some(credentials) = keychain::kept().await? else {
return Ok(false);
};
connect(&desktop, credentials).await?;
Ok(true)
}

async fn connect(desktop: &Desktop, credentials: Credentials) -> Result<(), String> {
let remote = remote_for(&credentials).map_err(|error| error.to_string())?;
*desktop.remote.lock().await = Some(remote);
*desktop.credentials.lock().await = Some(credentials);
Ok(())
}

fn remote_for(credentials: &Credentials) -> Result<Remote, RemoteError> {
Remote::with_app_password(
&credentials.server,
credentials.email.as_str(),
credentials.secret.as_str(),
)
}

fn this_computer() -> String {
let name = gethostname::gethostname().to_string_lossy().into_owned();
if name.is_empty() {
"this computer".to_owned()
} else {
name
}
}

#[derive(serde::Serialize)]
struct Available {
version: String,
Expand Down Expand Up @@ -106,9 +143,18 @@ async fn sign_out(desktop: State<'_, Desktop>) -> Result<(), String> {
if let Some(session) = desktop.sync.lock().await.take() {
session.stop().await;
}
desktop.remote.lock().await.take();
desktop.credentials.lock().await.take();
Ok(())
let revoked = match desktop.remote.lock().await.take() {
Some(remote) => match remote.revoke_own_app_password().await {
Ok(()) | Err(RemoteError::Unauthenticated) => Ok(()),
Err(error) => Err(format!(
"the server did not revoke this computer's app password, revoke it from the web app: {error}"
)),
},
None => Ok(()),
};
keychain::forget().await?;
revoked
}

#[tauri::command]
Expand Down Expand Up @@ -366,6 +412,7 @@ fn main() {
.manage(Desktop::default())
.invoke_handler(tauri::generate_handler![
login,
resume,
sign_out,
list_folder,
account,
Expand Down
Loading
Loading