From a9abf582f59b661f0b24ee3afafe2e6ec07b01e4 Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 23 Aug 2026 17:51:47 +0300 Subject: [PATCH] split accounts from sessions and hash tokens at rest --- cruciverb-server/src/admin.rs | 37 +++-- cruciverb-server/src/api_v1.rs | 4 +- cruciverb-server/src/db.rs | 241 +++++++++++++++++++++----------- cruciverb-server/src/routes.rs | 17 ++- cruciverb-server/src/session.rs | 49 ++++--- 5 files changed, 230 insertions(+), 118 deletions(-) diff --git a/cruciverb-server/src/admin.rs b/cruciverb-server/src/admin.rs index ccd05fc..179dd11 100644 --- a/cruciverb-server/src/admin.rs +++ b/cruciverb-server/src/admin.rs @@ -252,10 +252,12 @@ pub async fn admin_setup( State(state): State>, Json(req): Json, ) -> Result, Response> { - let dbi = state.db.lock().await; - - if db::has_admin_account(&dbi.db) { - return Err((StatusCode::FORBIDDEN, "admin already configured").into_response()); + // Optional first-run gate: if ADMIN_SETUP_KEY is set, a matching key must + // be supplied so a random visitor cannot claim the instance first. + if let Ok(key) = std::env::var("ADMIN_SETUP_KEY") { + if req.get("key").and_then(|v| v.as_str()) != Some(key.as_str()) { + return Err((StatusCode::UNAUTHORIZED, "invalid setup key").into_response()); + } } let username = req.get("username").and_then(|v| v.as_str()).unwrap_or(""); @@ -281,7 +283,20 @@ pub async fn admin_setup( } }; - db::create_admin_account(&dbi.db, username, &stored); + // Atomic check-and-create inside a transaction so two concurrent setup + // requests cannot both pass the "no admin yet" check and create two admins. + let dbi = state.db.lock().await; + let tx = match dbi.db.unchecked_transaction() { + Ok(tx) => tx, + Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()), + }; + if db::has_admin_account(&tx) { + return Err((StatusCode::FORBIDDEN, "admin already configured").into_response()); + } + db::create_admin_account(&tx, username, &stored); + if tx.commit().is_err() { + return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()); + } db::log_admin_action(&dbi.db, "admin_setup", Some(username), None); Ok(Json(serde_json::json!({ "ok": true }))) @@ -388,7 +403,7 @@ pub async fn stats( if let Ok(mut stmt) = conn.prepare( "SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END) FROM community_clues cc - LEFT JOIN sessions s ON cc.submitted_by = s.token + LEFT JOIN sessions s ON cc.submitted_by = s.account_id LEFT JOIN settings st ON cc.submitted_by = st.session_token GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10" ) { @@ -468,9 +483,9 @@ pub async fn list_sessions( check_admin(&headers).await?; let dbi = state.db.lock().await; let mut stmt = dbi.db.prepare( - "SELECT s.token, s.display_name, s.created_at, s.last_seen, - (SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.token AND sr.is_complete = 1), - (SELECT current_streak FROM streaks st WHERE st.session_token = s.token), + "SELECT s.account_id, s.display_name, s.created_at, s.last_seen, + (SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.account_id AND sr.is_complete = 1), + (SELECT current_streak FROM streaks st WHERE st.session_token = s.account_id), COALESCE(s.banned, 0) FROM sessions s ORDER BY s.last_seen DESC LIMIT 100" ).unwrap(); @@ -495,7 +510,7 @@ pub async fn delete_session( ) -> Result, Response> { check_admin(&headers).await?; let dbi = state.db.lock().await; - dbi.db.execute("DELETE FROM sessions WHERE token = ?1", [&token]).ok(); + dbi.db.execute("DELETE FROM sessions WHERE account_id = ?1", [&token]).ok(); dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok(); db::log_admin_action(&dbi.db, "delete_session", Some(&token), None); invalidate_stats_cache(); @@ -1395,7 +1410,7 @@ pub async fn list_community_puzzles( let dbi = state.db.lock().await; let mut stmt = dbi.db.prepare( "SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at - FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.token + FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.account_id WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200" ).unwrap(); let puzzles: Vec = stmt.query_map([], |row| { diff --git a/cruciverb-server/src/api_v1.rs b/cruciverb-server/src/api_v1.rs index 0aa7323..bdc66bf 100644 --- a/cruciverb-server/src/api_v1.rs +++ b/cruciverb-server/src/api_v1.rs @@ -751,7 +751,7 @@ pub async fn v1_create_pack( let id = Uuid::new_v4().to_string(); let dbi = state.db.lock().await; - let (_, author_name) = db::get_or_create_session(&dbi.db, &auth.session_token); + let author_name = db::account_display_name(&dbi.db, &auth.session_token); let ok = db::create_pack(&dbi.db, &id, &crate::security::sanitize_html(name), desc.map(|d| crate::security::sanitize_html(d)).as_deref(), @@ -774,7 +774,7 @@ pub async fn v1_me( let dbi = state.db.lock().await; let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token); let has_passkey = db::has_passkey(&dbi.db, &auth.session_token); - let (_, name) = db::get_or_create_session(&dbi.db, &auth.session_token); + let name = db::account_display_name(&dbi.db, &auth.session_token); api_ok(serde_json::json!({ "display_name": name, diff --git a/cruciverb-server/src/db.rs b/cruciverb-server/src/db.rs index a32c753..d7075cc 100644 --- a/cruciverb-server/src/db.rs +++ b/cruciverb-server/src/db.rs @@ -490,22 +490,73 @@ pub fn init_db(path: &Path) -> Connection { db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", []) .ok(); + // additive identity-refactor scaffolding (safe, behavior-unchanged): + // stable accounts alongside session credentials, so existing installs + // upgrade automatically on startup. + db.execute_batch( + "CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY + );", + ) + .unwrap(); + let has_account_col: bool = db + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'account_id'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0; + if !has_account_col { + db.execute("ALTER TABLE sessions ADD COLUMN account_id TEXT", []).ok(); + } + db.execute_batch( + "UPDATE sessions SET account_id = token WHERE account_id IS NULL;", + ) + .unwrap(); + db.execute_batch( + "INSERT OR IGNORE INTO accounts (id) SELECT DISTINCT account_id FROM sessions WHERE account_id IS NOT NULL;", + ) + .unwrap(); + + // one-time: hash legacy plaintext session tokens at rest. A session whose + // token still equals its account_id has not been hashed yet (after hashing + // they diverge), so this is idempotent across restarts and installs. + let legacy: Vec = { + let mut stmt = db + .prepare("SELECT token FROM sessions WHERE token = account_id") + .unwrap(); + let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); + rows.filter_map(Result::ok).collect() + }; + if !legacy.is_empty() { + if let Ok(tx) = db.unchecked_transaction() { + for t in legacy { + let _ = tx.execute( + "UPDATE sessions SET token = ?1 WHERE token = ?2", + params![token_hash(&t), &t], + ); + } + let _ = tx.commit(); + } + } + db } pub fn ban_session(db: &Connection, token: &str) { - db.execute("UPDATE sessions SET banned = 1 WHERE token = ?1", [token]) + db.execute("UPDATE sessions SET banned = 1 WHERE account_id = ?1", [token]) .ok(); } pub fn unban_session(db: &Connection, token: &str) { - db.execute("UPDATE sessions SET banned = 0 WHERE token = ?1", [token]) + db.execute("UPDATE sessions SET banned = 0 WHERE account_id = ?1", [token]) .ok(); } pub fn is_banned(db: &Connection, token: &str) -> bool { db.query_row( - "SELECT banned FROM sessions WHERE token = ?1", + "SELECT COUNT(*) FROM sessions WHERE account_id = ?1 AND banned = 1", [token], |r| r.get::<_, i64>(0), ) @@ -557,7 +608,7 @@ pub fn cleanup_user_content(db: &Connection, token: &str) { .ok(); // clear display name so deleted/banned users can't be looked up db.execute( - "UPDATE sessions SET display_name = NULL WHERE token = ?1", + "UPDATE sessions SET display_name = NULL WHERE account_id = ?1", [token], ) .ok(); @@ -845,30 +896,79 @@ pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String } // sessions +pub fn token_hash(token: &str) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(token.as_bytes())) +} + +fn new_account_id() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random::()).collect(); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +// Resolve a bearer token to its account id, creating a fresh account+session +// when unknown. Returns (account id, display name). pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option) { let now = chrono::Utc::now().to_rfc3339(); + let key = token_hash(token); let existing = db.query_row( - "SELECT token, display_name FROM sessions WHERE token = ?1", - [token], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + "SELECT account_id, display_name FROM sessions WHERE token = ?1", + [&key], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?)), ); - if let Ok((t, name)) = existing { + if let Ok((account_id, name)) = existing { + let account_id = account_id.unwrap_or_else(|| key.clone()); db.execute( "UPDATE sessions SET last_seen = ?1 WHERE token = ?2", - params![now, token], + params![now, &key], ) .ok(); - return (t, name); + return (account_id, name); } + let account_id = new_account_id(); + db.execute("INSERT OR IGNORE INTO accounts (id) VALUES (?1)", [&account_id]).ok(); db.execute( - "INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)", - params![token, now], + "INSERT INTO sessions (token, account_id, created_at, last_seen) VALUES (?1, ?2, ?3, ?3)", + params![&key, &account_id, now], ) .unwrap(); - (token.to_string(), None) + (account_id, None) +} + +// Mint a fresh session (new bearer) attached to an existing account, used by +// passkey login to resume an account on a new device. +pub fn create_session_for_account(db: &Connection, token: &str, account_id: &str) -> (String, Option) { + let now = chrono::Utc::now().to_rfc3339(); + let key = token_hash(token); + let name: Option = db + .query_row( + "SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1", + [account_id], + |r| r.get(0), + ) + .ok() + .flatten(); + db.execute( + "INSERT INTO sessions (token, account_id, display_name, created_at, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)", + params![&key, account_id, name, now], + ) + .unwrap(); + (account_id.to_string(), name) +} + +pub fn account_display_name(db: &Connection, account_id: &str) -> Option { + db.query_row( + "SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1", + [account_id], + |r| r.get(0), + ) + .ok() + .flatten() } pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option { @@ -891,7 +991,7 @@ pub fn set_user_agent_hash(db: &Connection, token: &str, hash: &str) { pub fn set_display_name(db: &Connection, token: &str, name: &str) { db.execute( - "UPDATE sessions SET display_name = ?1 WHERE token = ?2", + "UPDATE sessions SET display_name = ?1 WHERE account_id = ?2", params![name, token], ) .ok(); @@ -1123,8 +1223,13 @@ pub fn get_community_clues( } pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool { + let tx = match db.unchecked_transaction() { + Ok(tx) => tx, + Err(_) => return false, + }; + // check if already voted - let existing: Option = db + let existing: Option = tx .query_row( "SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2", params![token, clue_id], @@ -1134,65 +1239,57 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool if let Some(old_vote) = existing { if old_vote == vote { - return false; - } // same vote, no change + return false; // same vote, no change; tx rolls back on drop + } // undo old vote if old_vote > 0 { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1", [clue_id], - ) - .ok(); + ); } else { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1", [clue_id], - ) - .ok(); + ); } // apply new vote if vote > 0 { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1", [clue_id], - ) - .ok(); + ); } else { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1", [clue_id], - ) - .ok(); + ); } - db.execute( + let _ = tx.execute( "UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3", params![vote, token, clue_id], - ) - .ok(); + ); } else { // new vote if vote > 0 { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1", [clue_id], - ) - .ok(); + ); } else { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1", [clue_id], - ) - .ok(); + ); } - db.execute( + let _ = tx.execute( "INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)", params![token, clue_id, vote], - ) - .ok(); + ); } // auto-approve clues with net +3 votes, auto-reject at net -3 - let (up, down): (i64, i64) = db + let (up, down): (i64, i64) = tx .query_row( "SELECT upvotes, downvotes FROM community_clues WHERE id = ?1", [clue_id], @@ -1201,20 +1298,18 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool .unwrap_or((0, 0)); if up - down >= 3 { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'", [clue_id], - ) - .ok(); + ); } else if down - up >= 3 { - db.execute( + let _ = tx.execute( "UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'", [clue_id], - ) - .ok(); + ); } - true + tx.commit().is_ok() } pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> { @@ -1538,7 +1633,7 @@ pub fn list_published_puzzles( let mut stmt = db.prepare( "SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain FROM user_puzzles up - LEFT JOIN sessions s ON up.session_token = s.token + LEFT JOIN sessions s ON up.session_token = s.account_id LEFT JOIN settings st ON up.session_token = st.session_token WHERE up.status = 'published' AND up.clues_json IS NOT NULL ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2" @@ -2025,23 +2120,9 @@ pub fn import_user_data( imported += 1; } - // streak (only if better than current) - if let Some(streak) = data.get("streak") { - let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0); - let imp_current = streak.get("current").and_then(|v| v.as_i64()).unwrap_or(0); - let imp_last = streak - .get("last_date") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let (cur_current, cur_best, _) = get_streak(db, token); - if imp_best > cur_best as i64 || imp_current > cur_current as i64 { - db.execute( - "INSERT OR REPLACE INTO streaks (session_token, current_streak, best_streak, last_solve_date) VALUES (?1, ?2, ?3, ?4)", - params![token, std::cmp::max(imp_current, cur_current as i64), std::cmp::max(imp_best, cur_best as i64), imp_last], - ).ok(); - imported += 1; - } - } + // Ignore imported streak values: a client-supplied streak is untrusted and + // would let anyone inflate the streak leaderboard. Streaks are re-derived + // from actual solves by update_streak. // achievements (merge - only add ones not already unlocked) if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) { @@ -2199,7 +2280,7 @@ pub fn delete_account(db: &Connection, token: &str) { [token], ) .ok(); - db.execute("DELETE FROM sessions WHERE token = ?1", [token]) + db.execute("DELETE FROM sessions WHERE account_id = ?1", [token]) .ok(); } @@ -2207,7 +2288,7 @@ pub fn delete_account(db: &Connection, token: &str) { pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value { let session: serde_json::Value = db .query_row( - "SELECT display_name, created_at, last_seen FROM sessions WHERE token = ?1", + "SELECT display_name, created_at, last_seen FROM sessions WHERE account_id = ?1", [token], |row| { Ok(serde_json::json!({ @@ -2295,7 +2376,7 @@ pub fn get_leaderboard_filtered( let mut stmt = db.prepare( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) @@ -2325,7 +2406,7 @@ pub fn get_daily_leaderboard_filtered( let mut stmt = db.prepare( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 @@ -2405,8 +2486,8 @@ pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 { let sessions: Vec<(String, String, String)> = { let mut stmt = db .prepare( - "SELECT s.token, st.settings_json, s.last_seen FROM sessions s - JOIN settings st ON st.session_token = s.token", + "SELECT s.account_id, st.settings_json, s.last_seen FROM sessions s + JOIN settings st ON st.session_token = s.account_id", ) .unwrap(); stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) @@ -2738,7 +2819,7 @@ pub fn get_alltime_leaderboard( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used, p.daily_date FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 @@ -2771,7 +2852,7 @@ pub fn get_weekly_leaderboard( let mut stmt = db.prepare( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 @@ -2800,7 +2881,7 @@ pub fn get_streak_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64 let mut stmt = db.prepare( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak FROM streaks st2 - LEFT JOIN sessions s ON st2.session_token = s.token + LEFT JOIN sessions s ON st2.session_token = s.account_id LEFT JOIN settings st ON st2.session_token = st.session_token WHERE st2.current_streak > 0 AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) @@ -2826,7 +2907,7 @@ pub fn get_completionist_leaderboard(db: &Connection, limit: usize) -> Vec<(Stri let mut stmt = db.prepare( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE sr.is_complete = 1 AND sr.flagged = 0 AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) @@ -2857,7 +2938,7 @@ pub fn get_clean_leaderboard( let mut stmt = db.prepare( "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE p.daily_date = ?1 AND p.preset = ?2 @@ -3505,7 +3586,7 @@ pub fn get_daily_leaderboard_by_date( let sql = if clean_only { "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 @@ -3516,7 +3597,7 @@ pub fn get_daily_leaderboard_by_date( } else { "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used FROM solve_records sr - LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN sessions s ON sr.session_token = s.account_id LEFT JOIN puzzles p ON sr.puzzle_id = p.id LEFT JOIN settings st ON sr.session_token = st.session_token WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 diff --git a/cruciverb-server/src/routes.rs b/cruciverb-server/src/routes.rs index dc056ca..0e660e5 100644 --- a/cruciverb-server/src/routes.rs +++ b/cruciverb-server/src/routes.rs @@ -2701,17 +2701,22 @@ pub async fn passkey_auth_finish( } } - // create a session with the original token + // mint a fresh session attached to the account this passkey belongs to let dbi = state.db.lock().await; - let (token, name) = db::get_or_create_session(&dbi.db, &session_token); - let settings_json = db::get_settings(&dbi.db, &token); + let new_plain = crate::session::generate_token(); + let (account_id, name) = db::create_session_for_account(&dbi.db, &new_plain, &session_token); + let settings_json = db::get_settings(&dbi.db, &account_id); let cookie_max_age = serde_json::from_str::(&settings_json) .ok() .and_then(|v| v.get("cookieLifetime")?.as_u64()) .unwrap_or(2_592_000); let session = Session { - token, display_name: name, is_new: false, cookie_max_age, + token: account_id, + cookie_token: new_plain, + display_name: name, + is_new: true, + cookie_max_age, }; Ok(with_session_cookie(&session, Json(serde_json::json!({ @@ -2740,7 +2745,7 @@ pub async fn get_session_info( ) -> Result { let dbi = state.db.lock().await; let info = dbi.db.query_row( - "SELECT created_at, last_seen FROM sessions WHERE token = ?1", + "SELECT created_at, last_seen FROM sessions WHERE account_id = ?1", [&session.token], |row| Ok(serde_json::json!({ "created_at": row.get::<_, String>(0)?, @@ -3446,7 +3451,7 @@ pub async fn create_pack( let id = Uuid::new_v4().to_string(); let dbi = state.db.lock().await; - let (_, author_name) = db::get_or_create_session(&dbi.db, &session.token); + let author_name = db::account_display_name(&dbi.db, &session.token); let success = db::create_pack( &dbi.db, &id, &crate::security::sanitize_html(name), diff --git a/cruciverb-server/src/session.rs b/cruciverb-server/src/session.rs index f52b4c0..8256a97 100644 --- a/cruciverb-server/src/session.rs +++ b/cruciverb-server/src/session.rs @@ -9,6 +9,7 @@ use crate::state::AppState; pub struct Session { pub token: String, + pub cookie_token: String, pub display_name: Option, pub is_new: bool, pub cookie_max_age: u64, @@ -16,16 +17,16 @@ pub struct Session { impl Session { pub fn cookie_header(&self) -> String { - let secure = std::env::var("SECURE_COOKIES").as_deref() == Ok("true"); + let secure = std::env::var("SECURE_COOKIES").as_deref() != Ok("false"); let same_site = if secure { "Strict" } else { "Lax" }; let secure_flag = if secure { "; Secure" } else { "" }; if self.cookie_max_age == 0 { - format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.token, same_site, secure_flag) + format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.cookie_token, same_site, secure_flag) } else { format!( "cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}", - self.token, same_site, self.cookie_max_age, secure_flag + self.cookie_token, same_site, self.cookie_max_age, secure_flag ) } } @@ -38,7 +39,7 @@ impl FromRequestParts> for Session { parts: &mut Parts, state: &Arc, ) -> Result { - let token = parts + let cookie_token = parts .headers .get(header::COOKIE) .and_then(|v| v.to_str().ok()) @@ -57,15 +58,15 @@ impl FromRequestParts> for Session { .unwrap_or_default(); let dbi = state.db.lock().await; - let (mut token, mut name, mut is_new) = match token { - Some(t) if !t.is_empty() => { - let (token, name) = crate::db::get_or_create_session(&dbi.db, &t); - (token, name, false) + let (mut cookie_token, mut token, mut name, mut is_new) = match cookie_token { + Some(t) if is_valid_token(&t) => { + let (acct, name) = crate::db::get_or_create_session(&dbi.db, &t); + (t, acct, name, false) } _ => { let new_token = generate_token(); - let (token, name) = crate::db::get_or_create_session(&dbi.db, &new_token); - (token, name, true) + let (acct, name) = crate::db::get_or_create_session(&dbi.db, &new_token); + (new_token, acct, name, true) } }; @@ -82,14 +83,20 @@ impl FromRequestParts> for Session { .unwrap_or(false) && crate::db::has_passkey(&dbi.db, &token); if binding_enabled && !user_agent_hash.is_empty() { - let stored = crate::db::get_user_agent_hash(&dbi.db, &token); + let key = crate::db::token_hash(&cookie_token); + let stored = crate::db::get_user_agent_hash(&dbi.db, &key); match stored { - None => crate::db::set_user_agent_hash(&dbi.db, &token, &user_agent_hash), + None => crate::db::set_user_agent_hash(&dbi.db, &key, &user_agent_hash), Some(s) if s != user_agent_hash => { - let new_token = generate_token(); - let (t2, n2) = crate::db::get_or_create_session(&dbi.db, &new_token); - crate::db::set_user_agent_hash(&dbi.db, &t2, &user_agent_hash); - token = t2; + let new_plain = generate_token(); + let (a2, n2) = crate::db::get_or_create_session(&dbi.db, &new_plain); + crate::db::set_user_agent_hash( + &dbi.db, + &crate::db::token_hash(&new_plain), + &user_agent_hash, + ); + token = a2; + cookie_token = new_plain; name = n2; is_new = true; settings_json = crate::db::get_settings(&dbi.db, &token); @@ -105,11 +112,15 @@ impl FromRequestParts> for Session { .and_then(|v| v.get("cookieLifetime")?.as_u64()) .unwrap_or(2_592_000); - Ok(Session { token, display_name: name, is_new, cookie_max_age }) + Ok(Session { token, cookie_token, display_name: name, is_new, cookie_max_age }) } } -fn generate_token() -> String { +fn is_valid_token(t: &str) -> bool { + t.len() == 64 && t.chars().all(|c| c.is_ascii_hexdigit()) +} + +pub(crate) fn generate_token() -> String { let mut rng = rand::rng(); let bytes: Vec = (0..32).map(|_| rng.random::()).collect(); bytes.iter().map(|b| format!("{:02x}", b)).collect() @@ -118,4 +129,4 @@ fn generate_token() -> String { fn ua_hash(ua: &str) -> String { use sha2::{Digest, Sha256}; hex::encode(Sha256::digest(ua.as_bytes())) -} +} \ No newline at end of file