Files
twofauth-rs/src/sharing.rs
T
wt cf25e28c28
Rust verification / test (push) Successful in 4m10s
initial commit
2026-10-28 15:59:54 +07:00

1066 lines
37 KiB
Rust

//! Owner-controlled explicit/all-user shares. Recipients never receive secrets.
use crate::{ApiError, AppState, auth::AuthUser};
use axum::{
Extension, Json, Router,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, patch, post},
};
use serde_json::{Value, json};
use sqlx::{Row, SqliteConnection, SqlitePool};
use std::collections::{HashMap, HashSet};
type ResultResponse = Result<Response, ApiError>;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/twofaccounts/{id}/shares",
get(index).post(store).delete(destroy_all),
)
.route("/twofaccounts/{id}/shares/all", post(share_all))
.route(
"/twofaccounts/{id}/shares/{user}",
axum::routing::delete(destroy),
)
.route("/twofaccounts/{id}/recipients", get(recipients))
.route("/twofaccounts/{id}/owner", patch(transfer))
}
pub async fn flags(db: &SqlitePool) -> Result<(bool, bool), ApiError> {
let settings = crate::settings::all(db).await?;
Ok((
crate::settings::php_bool(&settings["enableSharing"]),
crate::settings::php_bool(&settings["enableAllUsersSharingScope"]),
))
}
/// Shared visibility and OTP permission; secret reads and mutations remain owner-only.
pub async fn can_access(db: &SqlitePool, user: i64, account: i64) -> Result<bool, ApiError> {
let (enabled, all) = flags(db).await?;
Ok(sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM twofaccounts a WHERE a.id=? AND (a.user_id=? OR (? AND EXISTS(SELECT 1 FROM twofaccount_shares s WHERE s.twofaccount_id=a.id AND ((s.scope='user' AND s.shared_with_user_id=?) OR (? AND s.scope='all_users'))))))")
.bind(account).bind(user).bind(enabled).bind(user).bind(all).fetch_one(db).await?)
}
async fn authorize(s: &AppState, user: i64, account: i64) -> Result<bool, ApiError> {
let (enabled, all) = flags(&s.db).await?;
if !enabled {
return Err(ApiError(
StatusCode::FORBIDDEN,
"Sharing feature is disabled",
));
}
let owner: Option<i64> = sqlx::query_scalar("SELECT user_id FROM twofaccounts WHERE id=?")
.bind(account)
.fetch_optional(&s.db)
.await?;
match owner {
None => Err(ApiError(StatusCode::NOT_FOUND, "not found")),
Some(owner) if owner != user => Err(ApiError(
StatusCode::FORBIDDEN,
"This action is unauthorized.",
)),
_ => Ok(all),
}
}
async fn verify_owner(
conn: &mut SqliteConnection,
user: i64,
account: i64,
) -> Result<(), ApiError> {
let valid: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM twofaccounts WHERE id=? AND user_id=?)")
.bind(account)
.bind(user)
.fetch_one(conn)
.await?;
if !valid {
return Err(ApiError(
StatusCode::FORBIDDEN,
"This action is unauthorized.",
));
}
Ok(())
}
async fn is_all(conn: &mut SqliteConnection, account: i64) -> Result<bool, ApiError> {
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users')").bind(account).fetch_one(conn).await?)
}
fn conflict() -> Response {
(
StatusCode::CONFLICT,
Json(
json!({"message":"conflict","reason":"This account is already shared with all users."}),
),
)
.into_response()
}
fn invalid(field: &str) -> Response {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({"message":"bad request","reason":{field:["The given data was invalid."]}})),
)
.into_response()
}
fn integer(v: &Value) -> Option<i64> {
v.as_i64().or_else(|| v.as_str()?.parse().ok())
}
async fn recipient_values(
db: &SqlitePool,
account: i64,
viewer: i64,
ids: Option<&[i64]>,
exclude: Option<i64>,
filter: Option<&str>,
) -> Result<Vec<Value>, ApiError> {
let raw: Option<String> = sqlx::query_scalar("SELECT preferences FROM users WHERE id=?")
.bind(viewer)
.fetch_optional(db)
.await?
.flatten();
let preferences = crate::settings::preferences_from(raw.as_deref());
let timezone = preferences["timezone"]
.as_str()
.and_then(|tz| tz.parse::<chrono_tz::Tz>().ok())
.unwrap_or(chrono_tz::UTC);
let rows = sqlx::query("SELECT u.id,u.name,u.email,(SELECT created_at FROM twofaccount_shares s WHERE s.twofaccount_id=? AND s.shared_with_user_id=u.id AND s.scope='user' LIMIT 1) AS shared_since,EXISTS(SELECT 1 FROM twofaccount_shares s WHERE s.twofaccount_id=? AND s.shared_with_user_id=u.id AND s.scope='user') AS shared FROM users u ORDER BY u.id").bind(account).bind(account).fetch_all(db).await?;
let mut result = vec![];
for row in rows {
let id: i64 = row.try_get("id")?;
if exclude == Some(id) || ids.is_some_and(|ids| !ids.contains(&id)) {
continue;
}
let name: String = row.try_get("name")?;
if let Some(filter) = filter {
let email: String = row.try_get("email")?;
if filter.is_empty()
|| (if filter.contains('@') {
email != filter
} else {
!name.to_lowercase().contains(&filter.to_lowercase())
})
{
continue;
}
}
let shared: bool = row.try_get("shared")?;
let mut value = json!({"id":id,"name":name,"is_shared_with":shared});
if shared {
let timestamp: Option<String> = row.try_get("shared_since")?;
value["is_shared_since"] = json!(timestamp.map(|t| {
chrono::NaiveDateTime::parse_from_str(&t, "%Y-%m-%d %H:%M:%S")
.map(|d| {
d.and_utc()
.with_timezone(&timezone)
.format("%a, %b %-d, %Y %-I:%M %p")
.to_string()
})
.unwrap_or(t)
}));
}
result.push(value);
}
Ok(result)
}
async fn index(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
Path(id): Path<i64>,
) -> ResultResponse {
let all = authorize(&s, u.id, id).await?;
let shared_all = if all {
let mut conn = s.db.acquire().await?;
is_all(&mut conn, id).await?
} else {
false
};
let mut value = json!({"twofaccount_id":id,"is_shared_with_all":shared_all});
if !shared_all {
let ids: Vec<i64> = sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user'").bind(id).fetch_all(&s.db).await?;
value["specific_users"] =
json!(recipient_values(&s.db, id, u.id, Some(&ids), None, None).await?);
}
Ok(Json(value).into_response())
}
async fn store(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
h: HeaderMap,
Path(id): Path<i64>,
Json(v): Json<Value>,
) -> ResultResponse {
let all = authorize(&s, u.id, id).await?;
let values = v
.get("user_ids")
.and_then(Value::as_array)
.cloned()
.or_else(|| v.get("user_id").map(|id| vec![id.clone()]));
let Some(values) = values else {
return Ok(invalid("user_ids"));
};
let ids: Option<Vec<i64>> = values.iter().map(integer).collect();
let Some(mut ids) = ids else {
return Ok(invalid("user_ids"));
};
if ids.is_empty()
|| ids.iter().collect::<HashSet<_>>().len() != ids.len()
|| ids.contains(&u.id)
{
return Ok(invalid("user_ids"));
}
ids.sort_unstable();
let mut tx = s.db.begin().await?;
verify_owner(&mut tx, u.id, id).await?;
if all && is_all(&mut tx, id).await? {
return Ok(conflict());
}
for target in &ids {
if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)")
.bind(target)
.fetch_one(&mut *tx)
.await?
{
return Ok(invalid("user_ids"));
}
}
if !all {
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users'")
.bind(id)
.execute(&mut *tx)
.await?;
}
let mut created = 0;
let mut notified = vec![];
for target in &ids {
let inserted = sqlx::query("INSERT OR IGNORE INTO twofaccount_shares(twofaccount_id,shared_with_user_id,scope,created_by_user_id,created_at,updated_at) VALUES(?,?,'user',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(id).bind(target).bind(u.id).execute(&mut *tx).await?.rows_affected();
created += inserted;
if inserted > 0 {
notified.push(*target);
}
}
tx.commit().await?;
notify_shares(&s, u.id, id, &notified, Notification::Shared, false, &h).await;
Ok((if created>0 {StatusCode::CREATED} else {StatusCode::OK},Json(json!({"twofaccount_id":id,"is_shared_with_all":false,"specific_users":recipient_values(&s.db,id,u.id,Some(&ids),None,None).await?}))).into_response())
}
async fn recipients(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
Path(id): Path<i64>,
Query(q): Query<HashMap<String, String>>,
) -> ResultResponse {
authorize(&s, u.id, id).await?;
if q.keys()
.any(|k| k.starts_with("filter[") && k != "filter[nameOrEmail]")
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"Requested filter is not allowed.",
));
}
Ok(Json(json!(
recipient_values(
&s.db,
id,
u.id,
None,
Some(u.id),
q.get("filter[nameOrEmail]").map(String::as_str)
)
.await?
))
.into_response())
}
async fn destroy(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
h: HeaderMap,
Path((id, target)): Path<(i64, i64)>,
) -> ResultResponse {
let all = authorize(&s, u.id, id).await?;
let mut tx = s.db.begin().await?;
verify_owner(&mut tx, u.id, id).await?;
if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)")
.bind(target)
.fetch_one(&mut *tx)
.await?
{
return Err(ApiError(StatusCode::NOT_FOUND, "not found"));
}
if all && is_all(&mut tx, id).await? {
return Ok(conflict());
}
sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND user_id=? AND EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND shared_with_user_id=? AND scope='user')").bind(id).bind(target).bind(id).bind(target).execute(&mut *tx).await?;
let deleted=sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND shared_with_user_id=? AND scope='user'").bind(id).bind(target).execute(&mut *tx).await?.rows_affected();
tx.commit().await?;
if deleted > 0 {
notify_shares(&s, u.id, id, &[target], Notification::Revoked, false, &h).await;
}
Ok(StatusCode::NO_CONTENT.into_response())
}
async fn destroy_all(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
h: HeaderMap,
Path(id): Path<i64>,
) -> ResultResponse {
let all = authorize(&s, u.id, id).await?;
let mut tx = s.db.begin().await?;
verify_owner(&mut tx, u.id, id).await?;
let explicit:Vec<i64>=sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id IS NOT NULL").bind(id).fetch_all(&mut *tx).await?;
let all_recipients: Vec<i64> = if all && is_all(&mut tx, id).await? {
sqlx::query_scalar("SELECT id FROM users WHERE id!=? ORDER BY id")
.bind(u.id)
.fetch_all(&mut *tx)
.await?
} else {
vec![]
};
sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND (user_id IN(SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user') OR (? AND user_id!=? AND EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users')))").bind(id).bind(id).bind(all).bind(u.id).bind(id).execute(&mut *tx).await?;
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND (scope='user' OR (? AND scope='all_users'))").bind(id).bind(all).execute(&mut *tx).await?;
tx.commit().await?;
notify_shares(&s, u.id, id, &explicit, Notification::Revoked, false, &h).await;
notify_shares(
&s,
u.id,
id,
&all_recipients,
Notification::Revoked,
true,
&h,
)
.await;
Ok(StatusCode::NO_CONTENT.into_response())
}
async fn share_all(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
h: HeaderMap,
Path(id): Path<i64>,
) -> ResultResponse {
if !authorize(&s, u.id, id).await? {
return Err(ApiError(
StatusCode::FORBIDDEN,
"All Users sharing scope is disabled",
));
}
let mut tx = s.db.begin().await?;
verify_owner(&mut tx, u.id, id).await?;
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user'")
.bind(id)
.execute(&mut *tx)
.await?;
let created = !is_all(&mut tx, id).await?;
if created {
sqlx::query("INSERT INTO twofaccount_shares(twofaccount_id,scope,created_by_user_id,created_at,updated_at) VALUES(?,'all_users',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(id).bind(u.id).execute(&mut *tx).await?;
}
tx.commit().await?;
if created {
let recipients: Vec<i64> =
sqlx::query_scalar("SELECT id FROM users WHERE id!=? ORDER BY id")
.bind(u.id)
.fetch_all(&s.db)
.await?;
notify_shares(&s, u.id, id, &recipients, Notification::Shared, true, &h).await;
}
Ok((
if created {
StatusCode::CREATED
} else {
StatusCode::OK
},
Json(json!({"twofaccount_id":id,"is_shared_with_all":true})),
)
.into_response())
}
async fn transfer(
State(s): State<AppState>,
Extension(u): Extension<AuthUser>,
h: HeaderMap,
Path(id): Path<i64>,
Json(v): Json<Value>,
) -> ResultResponse {
if !crate::auth::throttle_limit(&s.db, format!("ownership-transfer:{}", u.id), 6).await? {
return Err(ApiError(
StatusCode::TOO_MANY_REQUESTS,
"Too Many Attempts.",
));
}
let all = authorize(&s, u.id, id).await?;
let Some(target) = v.get("new_owner_id").and_then(integer) else {
return Ok(invalid("new_owner_id"));
};
let Some(password) = v
.get("confirm_password")
.and_then(Value::as_str)
.filter(|v| !v.is_empty())
else {
return Ok(invalid("confirm_password"));
};
if target == u.id {
return Ok((StatusCode::UNPROCESSABLE_ENTITY,Json(json!({"message":"The new owner must not be the current owner.","errors":{"new_owner_id":["The new owner must not be the current owner."]}}))).into_response());
}
if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)")
.bind(target)
.fetch_one(&s.db)
.await?
{
return Ok(invalid("new_owner_id"));
}
let hash: String = sqlx::query_scalar("SELECT password FROM users WHERE id=?")
.bind(u.id)
.fetch_one(&s.db)
.await?;
let password = password.to_owned();
let valid = tokio::task::spawn_blocking(move || crate::auth::verify(password, hash))
.await
.unwrap_or(false);
if !valid {
return Err(ApiError(StatusCode::UNAUTHORIZED, "unauthorized"));
}
let mut tx = s.db.begin().await?;
verify_owner(&mut tx, u.id, id).await?;
// Recheck ownership under the write transaction to prevent a stale authorization transfer.
let updated = sqlx::query(
"UPDATE twofaccounts SET user_id=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?",
)
.bind(target)
.bind(id)
.bind(u.id)
.execute(&mut *tx)
.await?
.rows_affected();
if updated == 0 {
return Err(ApiError(
StatusCode::FORBIDDEN,
"This action is unauthorized.",
));
}
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id=?").bind(id).bind(target).execute(&mut *tx).await?;
if !(all && is_all(&mut tx, id).await?) {
sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND user_id!=? AND user_id NOT IN(SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id IS NOT NULL)").bind(id).bind(target).bind(id).execute(&mut *tx).await?;
}
tx.commit().await?;
notify_shares(&s, u.id, id, &[target], Notification::Ownership, false, &h).await;
Ok(Json(json!({"twofaccount_id":id,"owner_id":target})).into_response())
}
#[derive(Clone, Copy)]
enum Notification {
Shared,
Revoked,
Ownership,
}
fn notification_locale(preferences: &Value, browser: &str) -> String {
preferences["lang"]
.as_str()
.filter(|v| *v != "browser")
.unwrap_or(browser)
.to_owned()
}
fn browser_locale(headers: &HeaderMap) -> String {
let mut languages: Vec<(&str, f32)> = headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.split(',')
.filter_map(|entry| {
let mut parts = entry.trim().split(';');
let name = parts.next()?;
let weight = parts
.next()
.and_then(|q| q.trim().strip_prefix("q="))
.and_then(|q| q.parse::<f32>().ok())
.unwrap_or(1.);
if name.is_empty() || !weight.is_finite() || weight <= 0. {
None
} else {
Some((name, weight))
}
})
.collect();
languages.sort_by(|a, b| b.1.total_cmp(&a.1));
languages
.first()
.map(|(language, _)| (*language).to_owned())
.unwrap_or_else(|| std::env::var("APP_FALLBACK_LOCALE").unwrap_or_else(|_| "en".into()))
}
fn notification_content(
kind: Notification,
all: bool,
locale: &str,
actor: &str,
label: &str,
recipient: &str,
url: &str,
) -> (String, String) {
let prefix = match kind {
Notification::Shared => "message.notifications.twofaccount_shared",
Notification::Revoked => "message.notifications.twofaccount_share_revoked",
Notification::Ownership => "message.notifications.twofaccount_ownership_transferred",
};
let replacements = [
("actor", actor),
("twofaccount", label),
("username", recipient),
];
let subject = crate::mail::translate(locale, &format!("{prefix}.subject"), &replacements);
let greeting =
crate::mail::translate(locale, "message.notifications.hello_user", &replacements);
let resume = match kind {
Notification::Ownership => format!("{prefix}.resume"),
_ => format!("{prefix}.resume_{}", if all { "all_users" } else { "user" }),
};
let mut lines = vec![
greeting,
crate::mail::translate(locale, &resume, &replacements),
];
let extra = match kind {
Notification::Shared => Some("use_it_to_generate_otp"),
Notification::Ownership => Some("check_shares"),
Notification::Revoked => None,
};
if let Some(extra) = extra {
lines.push(crate::mail::translate(
locale,
&format!("{prefix}.{extra}"),
&replacements,
));
}
lines.push(format!(
"{}: {url}",
crate::mail::translate(locale, "link.go_to_2fauth_host", &replacements)
));
(subject, lines.join("\n\n"))
}
async fn notify_shares(
s: &AppState,
actor: i64,
account: i64,
recipients: &[i64],
kind: Notification,
all: bool,
headers: &HeaderMap,
) {
if recipients.is_empty() {
return;
}
match prepare_notifications(s, actor, account, recipients, kind, all, headers).await {
Ok(deliveries) => {
for (email, subject, body) in deliveries {
if crate::mail::notify(&email, &subject, &body).await.is_err() {
// Upstream catches all delivery exceptions: the sharing action remains committed.
tracing::warn!("Sharing notification delivery failed");
}
}
}
Err(_) => tracing::warn!("Sharing notification could not be prepared"),
}
}
async fn prepare_notifications(
s: &AppState,
actor: i64,
account: i64,
recipients: &[i64],
kind: Notification,
all: bool,
headers: &HeaderMap,
) -> Result<Vec<(String, String, String)>, ApiError> {
// Select only printable account fields. OTP secrets never enter notification content.
let row = sqlx::query("SELECT service,account FROM twofaccounts WHERE id=?")
.bind(account)
.fetch_one(&s.db)
.await?;
let read = |field: &str| -> Result<String, ApiError> {
let text = row.try_get::<Option<String>, _>(field)?.unwrap_or_default();
if s.encryption_enabled() && !text.is_empty() {
s.cipher
.as_ref()
.ok_or(ApiError(
StatusCode::SERVICE_UNAVAILABLE,
"Encrypted notification label unavailable",
))?
.decrypt(&text)
.map_err(|_| {
ApiError(
StatusCode::SERVICE_UNAVAILABLE,
"Encrypted notification label unavailable",
)
})
} else {
Ok(text)
}
};
let service = read("service")?;
let name = read("account")?;
let label = if service.trim().is_empty() {
name.trim().to_owned()
} else {
format!("{} ({})", service.trim(), name.trim())
};
let actor = sqlx::query("SELECT name,preferences FROM users WHERE id=?")
.bind(actor)
.fetch_one(&s.db)
.await?;
let actor_name: String = actor.try_get("name")?;
let prefs = crate::settings::preferences_from(
actor
.try_get::<Option<String>, _>("preferences")?
.as_deref(),
);
let locale = notification_locale(&prefs, &browser_locale(headers));
let users = sqlx::query("SELECT id,name,email,preferences FROM users ORDER BY id")
.fetch_all(&s.db)
.await?;
let targets: HashSet<_> = recipients.iter().copied().collect();
let host = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into());
let mut deliveries = vec![];
for recipient in users {
if !targets.contains(&recipient.try_get::<i64, _>("id")?) {
continue;
}
let prefs = crate::settings::preferences_from(
recipient
.try_get::<Option<String>, _>("preferences")?
.as_deref(),
);
let preference = match kind {
Notification::Ownership => "notifyOnOwnershipTransfer",
_ => "notifyOnShare",
};
if !crate::settings::php_bool(&prefs[preference]) {
continue;
}
let locale = notification_locale(&prefs, &locale);
let (subject, body) = notification_content(
kind,
all,
&locale,
&actor_name,
&label,
recipient.try_get("name")?,
&host,
);
deliveries.push((recipient.try_get("email")?, subject, body));
}
Ok(deliveries)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::{Body, to_bytes},
http::Request,
};
use tower::ServiceExt;
async fn setup() -> AppState {
let db = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
crate::db::init(&db).await.unwrap();
crate::auth::init(&db).await.unwrap();
sqlx::raw_sql("INSERT INTO users(id,name,email,password) VALUES(1,'Alice','alice@example.org','unused'),(2,'Bob','bob@example.org','unused'),(3,'Carol','carol@example.org','unused'); INSERT INTO options(key,value) VALUES('enableSharing','{{1}}'),('enableAllUsersSharingScope','{{1}}'); INSERT INTO twofaccounts(id,user_id,account,otp_type,secret,algorithm,digits,counter) VALUES(1,1,'alice','hotp','GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ','sha1',6,0);").execute(&db).await.unwrap();
sqlx::query("UPDATE users SET preferences='{\"notifyOnShare\":false,\"notifyOnOwnershipTransfer\":false}'").execute(&db).await.unwrap();
AppState {
db,
cipher: None,
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
}
}
async fn request(
s: &AppState,
user: i64,
method: &str,
url: &str,
v: Value,
) -> (StatusCode, Value) {
let app = routes()
.merge(crate::accounts::routes())
.merge(crate::groups::routes())
.layer(Extension(AuthUser {
id: user,
is_admin: false,
}))
.with_state(s.clone());
let response = app
.oneshot(
Request::builder()
.method(method)
.uri(url)
.header("content-type", "application/json")
.body(Body::from(v.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
)
}
#[tokio::test]
async fn notification_targets_respect_preferences_locales_scope_and_hide_secrets() {
let mut s = setup().await;
sqlx::query("UPDATE users SET preferences=? WHERE id=2")
.bind(
json!({"notifyOnShare":true,"notifyOnOwnershipTransfer":false,"lang":"fr"})
.to_string(),
)
.execute(&s.db)
.await
.unwrap();
sqlx::query("UPDATE users SET preferences=? WHERE id=3")
.bind(
json!({"notifyOnShare":false,"notifyOnOwnershipTransfer":true,"lang":"en"})
.to_string(),
)
.execute(&s.db)
.await
.unwrap();
sqlx::query("UPDATE twofaccounts SET service=' Example ',account=' alice@example.org '")
.execute(&s.db)
.await
.unwrap();
let h = HeaderMap::new();
let deliveries =
prepare_notifications(&s, 1, 1, &[2, 3, 2], Notification::Shared, false, &h)
.await
.unwrap();
assert_eq!(deliveries.len(), 1);
assert_eq!(deliveries[0].0, "bob@example.org");
assert_eq!(
deliveries[0].1,
crate::mail::translate(
"fr",
"message.notifications.twofaccount_shared.subject",
&[]
)
);
assert!(deliveries[0].2.contains("Example (alice@example.org)"));
assert!(deliveries[0].2.contains("Alice"));
assert!(!deliveries[0].2.contains("GEZDGNBV"));
let ownership =
prepare_notifications(&s, 1, 1, &[2, 3], Notification::Ownership, false, &h)
.await
.unwrap();
assert_eq!(ownership.len(), 1);
assert_eq!(ownership[0].0, "carol@example.org");
assert!(ownership[0].2.contains("review"));
let (subject, body) = notification_content(
Notification::Revoked,
true,
"en",
"Alice",
"Account",
"Bob",
"https://example.org",
);
assert_eq!(subject, "2FA account sharing revoked");
assert!(body.contains("for all users"));
assert!(body.contains("https://example.org"));
assert!(!body.contains("You can now"));
assert!(
prepare_notifications(&s, 1, 1, &[1], Notification::Shared, false, &h)
.await
.unwrap()
.is_empty()
);
s.cipher = Some(std::sync::Arc::new(
crate::crypto::LaravelCipher::from_app_key(
"base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
)
.unwrap(),
));
s.encrypt_db
.store(true, std::sync::atomic::Ordering::SeqCst);
let cipher = s.cipher.as_ref().unwrap();
sqlx::query("UPDATE twofaccounts SET account=?,service=?,secret='must-never-be-read'")
.bind(cipher.encrypt("hidden@example.org").unwrap())
.bind(cipher.encrypt("Encrypted Label").unwrap())
.execute(&s.db)
.await
.unwrap();
let encrypted = prepare_notifications(&s, 1, 1, &[2], Notification::Shared, false, &h)
.await
.unwrap();
assert!(
encrypted[0]
.2
.contains("Encrypted Label (hidden@example.org)")
);
assert!(!encrypted[0].2.contains("must-never-be-read"));
}
#[tokio::test]
async fn explicit_share_is_idempotent_and_read_only_with_independent_groups() {
let s = setup().await;
let (status, v) = request(
&s,
1,
"POST",
"/twofaccounts/1/shares",
json!({"user_ids":[2]}),
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(v["specific_users"][0]["is_shared_with"], true);
assert_eq!(
request(
&s,
1,
"POST",
"/twofaccounts/1/shares",
json!({"user_id":"2"})
)
.await
.0,
StatusCode::OK
);
let (_, v) = request(&s, 2, "GET", "/twofaccounts?withSecret=true", Value::Null).await;
assert_eq!(v[0]["is_borrowed"], true);
assert_eq!(v[0]["borrowed_by"], "Alice");
assert!(v[0].get("secret").is_none());
let (_, v) = request(&s, 2, "GET", "/twofaccounts/1?withSecret=true", Value::Null).await;
assert!(v.get("secret").is_none());
assert_eq!(
request(&s, 2, "GET", "/twofaccounts/1/otp", Value::Null)
.await
.1["password"],
"755224"
);
assert_eq!(
request(&s, 2, "GET", "/twofaccounts/1/otp-logs", Value::Null)
.await
.0,
StatusCode::FORBIDDEN
);
let (_, logs) = request(
&s,
1,
"GET",
"/twofaccounts/1/otp-logs?limit=1",
Value::Null,
)
.await;
assert_eq!(logs[0]["requester_name"], "Bob");
assert_eq!(logs[0]["owner_name"], "Alice");
assert_eq!(logs[0]["counter"], 1);
assert!(logs[0].get("secret").is_none());
assert_eq!(
request(&s, 2, "GET", "/twofaccounts/export?ids=1", Value::Null)
.await
.0,
StatusCode::FORBIDDEN
);
assert_eq!(
request(&s, 2, "DELETE", "/twofaccounts/1", Value::Null)
.await
.0,
StatusCode::FORBIDDEN
);
assert_eq!(
request(&s, 2, "DELETE", "/twofaccounts/1/shares", Value::Null)
.await
.0,
StatusCode::FORBIDDEN
);
let (_, group) = request(&s, 2, "POST", "/groups", json!({"name":"Borrowed"})).await;
let group = group["id"].as_i64().unwrap();
assert_eq!(
request(
&s,
2,
"POST",
&format!("/groups/{group}/assign"),
json!({"ids":[1]})
)
.await
.0,
StatusCode::OK
);
assert_eq!(
request(&s, 2, "GET", "/twofaccounts/1", Value::Null)
.await
.1["group_id"],
group
);
assert!(
request(&s, 1, "GET", "/twofaccounts/1", Value::Null)
.await
.1["group_id"]
.is_null()
);
assert_eq!(
request(&s, 2, "GET", "/twofaccounts/count", Value::Null)
.await
.1,
json!({"owned":0,"shared":1,"total":1})
);
assert_eq!(
request(
&s,
2,
"POST",
"/twofaccounts/reorder",
json!({"orderedIds":[1]})
)
.await
.0,
StatusCode::OK
);
request(&s, 1, "DELETE", "/twofaccounts/1/shares/2", Value::Null).await;
let remaining: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM twofaccount_user_orders WHERE user_id=2")
.fetch_one(&s.db)
.await
.unwrap();
assert_eq!(remaining, 0);
assert_eq!(
request(&s, 2, "GET", "/twofaccounts", Value::Null).await.1,
json!([])
);
assert_eq!(
request(&s, 2, "GET", &format!("/groups/{group}"), Value::Null)
.await
.1["twofaccounts_count"],
0
);
}
#[tokio::test]
async fn all_scope_conflict_disablement_and_batch_validation_are_atomic() {
let s = setup().await;
assert_eq!(
request(
&s,
1,
"POST",
"/twofaccounts/1/shares",
json!({"user_ids":[2,999]})
)
.await
.0,
StatusCode::UNPROCESSABLE_ENTITY
);
assert!(!can_access(&s.db, 2, 1).await.unwrap());
assert_eq!(
request(&s, 1, "POST", "/twofaccounts/1/shares/all", json!({}))
.await
.0,
StatusCode::CREATED
);
assert_eq!(
request(&s, 1, "POST", "/twofaccounts/1/shares/all", json!({}))
.await
.0,
StatusCode::OK
);
assert!(can_access(&s.db, 3, 1).await.unwrap());
assert_eq!(
request(
&s,
1,
"POST",
"/twofaccounts/1/shares",
json!({"user_ids":[2]})
)
.await
.0,
StatusCode::CONFLICT
);
sqlx::query("UPDATE options SET value='{{}}' WHERE key='enableAllUsersSharingScope'")
.execute(&s.db)
.await
.unwrap();
assert!(!can_access(&s.db, 3, 1).await.unwrap());
assert_eq!(
request(
&s,
1,
"POST",
"/twofaccounts/1/shares",
json!({"user_ids":[2]})
)
.await
.0,
StatusCode::CREATED
);
assert!(can_access(&s.db, 2, 1).await.unwrap());
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM twofaccount_shares WHERE scope='all_users'")
.fetch_one(&s.db)
.await
.unwrap();
assert_eq!(count, 0);
sqlx::query("UPDATE options SET value='{{}}' WHERE key='enableSharing'")
.execute(&s.db)
.await
.unwrap();
assert!(!can_access(&s.db, 2, 1).await.unwrap());
assert_eq!(
request(&s, 1, "GET", "/twofaccounts/1/shares", Value::Null)
.await
.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn transfer_requires_password_removes_new_owner_share_and_preserves_others() {
let s = setup().await;
let hash = bcrypt::hash("password", 4).unwrap();
sqlx::query("UPDATE users SET password=? WHERE id=1")
.bind(hash)
.execute(&s.db)
.await
.unwrap();
request(
&s,
1,
"POST",
"/twofaccounts/1/shares",
json!({"user_ids":[2,3]}),
)
.await;
sqlx::query("INSERT INTO twofaccount_user_orders(user_id,twofaccount_id,position) VALUES(1,1,1),(2,1,1),(3,1,1)").execute(&s.db).await.unwrap();
assert_eq!(
request(
&s,
1,
"PATCH",
"/twofaccounts/1/owner",
json!({"new_owner_id":2,"confirm_password":"wrong"})
)
.await
.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
request(
&s,
1,
"PATCH",
"/twofaccounts/1/owner",
json!({"new_owner_id":2,"confirm_password":"password"})
)
.await
.0,
StatusCode::OK
);
let (_, v) = request(&s, 2, "GET", "/twofaccounts/1", Value::Null).await;
assert!(v.get("secret").is_some());
assert!(!can_access(&s.db, 1, 1).await.unwrap());
assert!(can_access(&s.db, 3, 1).await.unwrap());
let ids: Vec<i64> =
sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares")
.fetch_all(&s.db)
.await
.unwrap();
assert_eq!(ids, vec![3]);
let orders: Vec<i64> =
sqlx::query_scalar("SELECT user_id FROM twofaccount_user_orders ORDER BY user_id")
.fetch_all(&s.db)
.await
.unwrap();
assert_eq!(orders, vec![2, 3]);
}
}