feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
//! Database access for profiles.
|
||||
//!
|
||||
//! Everything here takes an explicit `user_id`. There is no ambient "current
|
||||
//! user" in this module — the caller has to say who it means, which is what
|
||||
//! stops a switch half-applying and writing one profile's state under another's
|
||||
//! id.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083 | DR-267, DR-268
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::pin::PinState;
|
||||
use super::{Profile, UnlockMethod};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// List every profile known for a server, most recently used first.
|
||||
///
|
||||
/// A profile's unlock method is derived from the presence of a `user_pins` row
|
||||
/// rather than stored twice, so the two can never disagree.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn list_profiles(
|
||||
db: &Arc<RusqliteService>,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<Profile>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
|
||||
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
|
||||
FROM users u
|
||||
LEFT JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.server_id = ?
|
||||
ORDER BY u.last_login_at DESC",
|
||||
vec![QueryParam::String(server_id.to_string())],
|
||||
);
|
||||
|
||||
db.query_many(query, |row| {
|
||||
let has_pin: i32 = row.get(5)?;
|
||||
Ok(Profile {
|
||||
user_id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
server_id: row.get(2)?,
|
||||
avatar_tag: None,
|
||||
unlock_method: if has_pin != 0 {
|
||||
UnlockMethod::Pin
|
||||
} else {
|
||||
UnlockMethod::None
|
||||
},
|
||||
last_used_at: row.get(4)?,
|
||||
is_active: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch a single profile, or `None` if this device does not know it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn get_profile(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Profile>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
|
||||
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
|
||||
FROM users u
|
||||
LEFT JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
db.query_optional(query, |row| {
|
||||
let has_pin: i32 = row.get(5)?;
|
||||
Ok(Profile {
|
||||
user_id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
server_id: row.get(2)?,
|
||||
avatar_tag: None,
|
||||
unlock_method: if has_pin != 0 {
|
||||
UnlockMethod::Pin
|
||||
} else {
|
||||
UnlockMethod::None
|
||||
},
|
||||
last_used_at: row.get(4)?,
|
||||
is_active: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn get_pin(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<(String, PinState)>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
let row: Option<(String, i64, Option<String>)> = db
|
||||
.query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(row.map(|(hash, failed, locked)| {
|
||||
let locked_until = locked
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
(
|
||||
hash,
|
||||
PinState {
|
||||
failed_count: failed.max(0) as u32,
|
||||
locked_until,
|
||||
},
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Store (or replace) a profile's PIN, resetting its counters.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn set_pin(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
pin_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
|
||||
VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
pin_hash = excluded.pin_hash,
|
||||
failed_count = 0,
|
||||
locked_until = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(pin_hash.to_string()),
|
||||
],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a profile's PIN, making it a one-tap profile.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM user_pins WHERE user_id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the counter state produced by [`super::pin::evaluate`].
|
||||
///
|
||||
/// This is what makes a lockout survive a restart: the deadline is on disk, not
|
||||
/// in a process-lifetime counter that closing the app would clear.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn save_pin_state(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
state: &PinState,
|
||||
) -> Result<(), String> {
|
||||
let locked = match state.locked_until {
|
||||
Some(dt) => QueryParam::String(dt.to_rfc3339()),
|
||||
None => QueryParam::Null,
|
||||
};
|
||||
let query = Query::with_params(
|
||||
"UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
|
||||
vec![
|
||||
QueryParam::Int64(i64::from(state.failed_count)),
|
||||
locked,
|
||||
QueryParam::String(user_id.to_string()),
|
||||
],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a profile: its PIN, its per-user rows, and its `users` row.
|
||||
///
|
||||
/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
|
||||
/// from this device is a local act; invalidating a token the person may be using
|
||||
/// on their phone is not what "remove from this TV" means. The caller deletes the
|
||||
/// stored token separately.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
// ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
|
||||
// user_libraries, download_grants and the rest; the users row is the root.
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM users WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user