Multi-user: email sign-in, invite/open registration, admin calls
- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+120
-7
@@ -490,17 +490,110 @@ impl WebCore {
|
||||
api: info.api,
|
||||
setup_open: info.setup_open,
|
||||
min_client: info.min_client,
|
||||
registration: info.registration.map(registration_name),
|
||||
})
|
||||
}
|
||||
|
||||
/// First device only: registers the account with a fresh server using its one-time setup token.
|
||||
pub async fn register_account(&self, setup_token: &str) -> Result<()> {
|
||||
pub async fn account_details(&self) -> Result<AccountDetailsDto> {
|
||||
let info = self.api()?.account().await?;
|
||||
Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin })
|
||||
}
|
||||
|
||||
pub async fn set_account_email(&self, email: &str) -> Result<()> {
|
||||
Ok(self.api()?.set_email(email).await?)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ admin (docs/03 "Accounts")
|
||||
|
||||
pub async fn admin_users(&self) -> Result<Vec<AdminUserDto>> {
|
||||
Ok(self
|
||||
.api()?
|
||||
.admin_users()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| AdminUserDto {
|
||||
account_id: u.account_id.to_string(),
|
||||
email: u.email,
|
||||
is_admin: u.is_admin,
|
||||
disabled: u.disabled,
|
||||
created_at: u.created_at,
|
||||
last_seen: u.last_seen,
|
||||
devices: u.devices,
|
||||
items: u.items,
|
||||
storage_bytes: u.storage_bytes,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn admin_update_user(
|
||||
&self,
|
||||
account_id: &str,
|
||||
disabled: Option<bool>,
|
||||
is_admin: Option<bool>,
|
||||
) -> Result<()> {
|
||||
let update = kv_proto::AdminUserUpdate { disabled, is_admin };
|
||||
Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?)
|
||||
}
|
||||
|
||||
pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> {
|
||||
Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?)
|
||||
}
|
||||
|
||||
pub async fn admin_invites(&self) -> Result<Vec<InviteDto>> {
|
||||
Ok(self
|
||||
.api()?
|
||||
.admin_invites()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|i| InviteDto {
|
||||
invite_id: i.invite_id.to_string(),
|
||||
email: i.email,
|
||||
created_at: i.created_at,
|
||||
expires_at: i.expires_at,
|
||||
used: i.used,
|
||||
revoked: i.revoked,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCreatedDto> {
|
||||
let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?;
|
||||
Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at })
|
||||
}
|
||||
|
||||
pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> {
|
||||
Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?)
|
||||
}
|
||||
|
||||
pub async fn admin_settings(&self) -> Result<AdminSettingsDto> {
|
||||
let s = self.api()?.admin_settings().await?;
|
||||
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
|
||||
}
|
||||
|
||||
pub async fn admin_put_settings(
|
||||
&self,
|
||||
registration: &str,
|
||||
allowed_domains: Vec<String>,
|
||||
) -> Result<AdminSettingsDto> {
|
||||
let registration = match registration {
|
||||
"open" => kv_proto::Registration::Open,
|
||||
"invite" => kv_proto::Registration::Invite,
|
||||
other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }),
|
||||
};
|
||||
let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?;
|
||||
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
|
||||
}
|
||||
|
||||
/// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty
|
||||
/// with open sign-up; `email` is required once the server has an account.
|
||||
pub async fn register_account(&self, setup_token: &str, email: Option<String>) -> Result<()> {
|
||||
let api = self.api()?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||
let device = self.enroll()?;
|
||||
api.register(
|
||||
setup_token,
|
||||
email.as_deref(),
|
||||
acct.account_id,
|
||||
&acct.kdf,
|
||||
auth.as_ref(),
|
||||
@@ -515,9 +608,15 @@ impl WebCore {
|
||||
|
||||
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
|
||||
/// server; call `sync_now` afterwards to download the vault.
|
||||
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
|
||||
pub async fn login(
|
||||
&self,
|
||||
master_password: &str,
|
||||
secret_key: &str,
|
||||
totp: Option<String>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
|
||||
self.login_inner(master_password, sk, totp, None, None).await
|
||||
self.login_inner(master_password, sk, totp, None, None, email).await
|
||||
}
|
||||
|
||||
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
|
||||
@@ -528,8 +627,9 @@ impl WebCore {
|
||||
totp: Option<String>,
|
||||
ticket: Option<String>,
|
||||
expect_account: Option<Uuid>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account).await;
|
||||
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await;
|
||||
r.inspect_err(|_| self.forget_secret_key())
|
||||
}
|
||||
|
||||
@@ -540,13 +640,19 @@ impl WebCore {
|
||||
totp: Option<String>,
|
||||
ticket: Option<String>,
|
||||
expect_account: Option<Uuid>,
|
||||
email: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let api = self.api()?;
|
||||
let local = self.lock_inner().account.clone();
|
||||
let (account_id, kdf_params) = match &local {
|
||||
Some(a) => (a.account_id, a.kdf.clone()),
|
||||
None => {
|
||||
let pre = api.prelogin().await?;
|
||||
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
|
||||
(Some(id), _) => kv_sync::PreloginBy::Account(id),
|
||||
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
|
||||
(None, None) => kv_sync::PreloginBy::OnlyAccount,
|
||||
};
|
||||
let pre = api.prelogin(by).await?;
|
||||
(pre.account_id, pre.kdf)
|
||||
}
|
||||
};
|
||||
@@ -720,7 +826,7 @@ impl WebCore {
|
||||
}
|
||||
self.configure_server(&p.server_url, p.spki_pins.clone())?;
|
||||
let sk = SecretKey::parse(&p.secret_key)?;
|
||||
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await
|
||||
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
|
||||
}
|
||||
|
||||
/// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call
|
||||
@@ -1169,3 +1275,10 @@ pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
|
||||
fn parse_id(s: &str) -> Result<Uuid> {
|
||||
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
|
||||
}
|
||||
|
||||
fn registration_name(r: kv_proto::Registration) -> String {
|
||||
match r {
|
||||
kv_proto::Registration::Invite => "invite".into(),
|
||||
kv_proto::Registration::Open => "open".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,56 @@ pub struct ServerInfoDto {
|
||||
pub api: u32,
|
||||
pub setup_open: bool,
|
||||
pub min_client: String,
|
||||
/// "invite" or "open" (absent on servers from before multi-user).
|
||||
pub registration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountDetailsDto {
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUserDto {
|
||||
pub account_id: String,
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
pub disabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen: Option<i64>,
|
||||
pub devices: u32,
|
||||
pub items: u32,
|
||||
pub storage_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteDto {
|
||||
pub invite_id: String,
|
||||
pub email: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub used: bool,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCreatedDto {
|
||||
pub invite_id: String,
|
||||
pub code: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSettingsDto {
|
||||
/// "invite" or "open".
|
||||
pub registration: String,
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -62,7 +62,12 @@ export interface ItemDto {
|
||||
|
||||
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
|
||||
|
||||
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; }
|
||||
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; registration: "invite" | "open" | null; }
|
||||
export interface AccountDetailsDto { email: string | null; isAdmin: boolean; }
|
||||
export interface AdminUserDto { accountId: string; email: string | null; isAdmin: boolean; disabled: boolean; createdAt: string; lastSeen: number | null; devices: number; items: number; storageBytes: number; }
|
||||
export interface InviteDto { inviteId: string; email: string | null; createdAt: number; expiresAt: number; used: boolean; revoked: boolean; }
|
||||
export interface InviteCreatedDto { inviteId: string; code: string; expiresAt: number; }
|
||||
export interface AdminSettingsDto { registration: "invite" | "open"; allowedDomains: string[]; }
|
||||
|
||||
export interface SyncReport {
|
||||
pulled: number;
|
||||
@@ -648,8 +653,12 @@ impl VaultCore {
|
||||
|
||||
/// First device only (the web vault is rarely that): registers the account with a fresh server.
|
||||
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
|
||||
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
|
||||
self.write(move |s| async move { s.core.register_account(&setup_token).await })
|
||||
pub fn register_account(
|
||||
&self,
|
||||
#[wasm_bindgen(js_name = "setupToken")] setup_token: String,
|
||||
email: Option<String>,
|
||||
) -> Promise {
|
||||
self.write(move |s| async move { s.core.register_account(&setup_token, email).await })
|
||||
}
|
||||
|
||||
/// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this
|
||||
@@ -660,9 +669,69 @@ impl VaultCore {
|
||||
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
|
||||
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
||||
totp: Option<String>,
|
||||
email: Option<String>,
|
||||
) -> Promise {
|
||||
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
|
||||
self.write(move |s| async move { s.core.login(&pw, &sk, totp).await })
|
||||
self.write(move |s| async move { s.core.login(&pw, &sk, totp, email).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = accountDetails, unchecked_return_type = "Promise<AccountDetailsDto>")]
|
||||
pub fn account_details(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.account_details().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = setAccountEmail, unchecked_return_type = "Promise<void>")]
|
||||
pub fn set_account_email(&self, email: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.set_account_email(&email).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminUsers, unchecked_return_type = "Promise<AdminUserDto[]>")]
|
||||
pub fn admin_users(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.admin_users().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminUpdateUser, unchecked_return_type = "Promise<void>")]
|
||||
pub fn admin_update_user(
|
||||
&self,
|
||||
#[wasm_bindgen(js_name = "accountId")] account_id: String,
|
||||
disabled: Option<bool>,
|
||||
#[wasm_bindgen(js_name = "isAdmin")] is_admin: Option<bool>,
|
||||
) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_update_user(&account_id, disabled, is_admin).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminDeleteUser, unchecked_return_type = "Promise<void>")]
|
||||
pub fn admin_delete_user(&self, #[wasm_bindgen(js_name = "accountId")] account_id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_delete_user(&account_id).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminInvites, unchecked_return_type = "Promise<InviteDto[]>")]
|
||||
pub fn admin_invites(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.admin_invites().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminCreateInvite, unchecked_return_type = "Promise<InviteCreatedDto>")]
|
||||
pub fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_create_invite(email, days).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise<void>")]
|
||||
pub fn admin_revoke_invite(&self, #[wasm_bindgen(js_name = "inviteId")] invite_id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_revoke_invite(&invite_id).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
|
||||
pub fn admin_settings(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.admin_settings().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = adminPutSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
|
||||
pub fn admin_put_settings(
|
||||
&self,
|
||||
registration: String,
|
||||
#[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec<String>,
|
||||
) -> Promise {
|
||||
self.run(move |s| async move { s.core.admin_put_settings(®istration, allowed_domains).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]
|
||||
|
||||
@@ -70,7 +70,7 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
app.configure_server(server.url(), vec![]).unwrap();
|
||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let mut on_app = kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
kind: kv_ffi::ItemKindDto::Login,
|
||||
@@ -93,12 +93,12 @@ async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||
let web = web_core(store.clone(), &server.url(), signer.clone());
|
||||
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!web.has_account());
|
||||
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
||||
app.approve_device(pending.device_id).await.unwrap();
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
|
||||
assert_eq!(web.login(PW, &sk, None, None).await.unwrap(), LoginResult::Ok);
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
||||
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
|
||||
@@ -158,7 +158,7 @@ async fn concurrent_sync_is_refused_not_interleaved() {
|
||||
let server = TestServer::start().await;
|
||||
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
||||
let sk = web.create_account(PW).unwrap();
|
||||
web.register_account(SETUP_TOKEN).await.unwrap();
|
||||
web.register_account(SETUP_TOKEN, None).await.unwrap();
|
||||
web.save_item(web_item("A", "1")).unwrap();
|
||||
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
|
||||
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
|
||||
@@ -176,7 +176,7 @@ async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String,
|
||||
app.configure_server(server.url(), vec![]).unwrap();
|
||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||
let id = app
|
||||
.save_item(kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
@@ -254,7 +254,7 @@ async fn web_vault_enrolls_by_ticket_both_ways() {
|
||||
assert!(matches!(web.unlock("wrong password!!", &sk), Err(CoreError::BadCredentials)));
|
||||
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
assert!(web.login("wrong password!!", &sk, None).await.is_err());
|
||||
assert!(web.login("wrong password!!", &sk, None, None).await.is_err());
|
||||
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user