From 7bc8e8b7262a3f3abe3222b3b434838e85cdb2bb Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Sun, 29 Dec 2024 20:37:26 +0100 Subject: Rework auth to include session The actual authentication is still fake. --- server/Cargo.lock | 121 +++++++++++++++++++++++- server/Cargo.toml | 3 +- server/Rocket.toml | 12 +++ server/migrations/eyeballs.sql | 2 +- server/src/api_model.rs | 9 +- server/src/auth.rs | 207 +++++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 36 ++----- 7 files changed, 357 insertions(+), 33 deletions(-) create mode 100644 server/src/auth.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 181720a..865b62d 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.11" @@ -129,6 +164,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -192,6 +233,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -204,7 +255,13 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64 0.22.1", + "hkdf", "percent-encoding", + "rand", + "sha2", + "subtle", "time", "version_check", ] @@ -255,9 +312,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "der" version = "0.7.9" @@ -400,6 +467,7 @@ dependencies = [ "rocket_db_pools", "serde", "sqlx", + "time", ] [[package]] @@ -582,6 +650,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -917,6 +995,15 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "is-terminal" version = "0.4.13" @@ -1183,6 +1270,12 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "overload" version = "0.1.1" @@ -1295,6 +1388,18 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1619,7 +1724,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -1916,7 +2021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "bytes", @@ -1958,7 +2063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "crc", @@ -2414,6 +2519,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/server/Cargo.toml b/server/Cargo.toml index c78b9f7..fc8cb6f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -5,7 +5,8 @@ edition = "2021" [dependencies] futures = "0.3.31" -rocket = { version = "0.5.1", features = ["json"] } +rocket = { version = "0.5.1", features = ["json", "secrets"] } rocket_db_pools = { version = "0.2.0", features = ["sqlx_mysql"] } serde = { version = "1.0", features = ["derive"] } sqlx = { version = "0.7.0", default-features = false, features = ["macros", "migrate"] } +time = "0.3.34" diff --git a/server/Rocket.toml b/server/Rocket.toml index 06369e5..6dd1ad1 100644 --- a/server/Rocket.toml +++ b/server/Rocket.toml @@ -1,2 +1,14 @@ +[default] +secret_key = "itlYmFR2vYKrOmFhupMIn/hyB6lYCCTXz4yaQX89XVg=" +session_max_age_days = 7 + [default.databases.eyeballs] url = "mysql://eyeballs:verysecret@127.0.0.1:3306/eyeballs" + +[release] +secret_key = "intentionally invalid" +session_max_age_days = 30 + +[release.databases.eyeballs] +url = "intentionally invalid" + diff --git a/server/migrations/eyeballs.sql b/server/migrations/eyeballs.sql index d8163b2..9699e6c 100644 --- a/server/migrations/eyeballs.sql +++ b/server/migrations/eyeballs.sql @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS users ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, - username VARCHAR(256) NOT NULL, + username VARCHAR(256) NOT NULL UNIQUE, name VARCHAR(1024) NOT NULL DEFAULT '', active BOOLEAN NOT NULL DEFAULT 1 ); diff --git a/server/src/api_model.rs b/server/src/api_model.rs index 1c02f6c..286e11f 100644 --- a/server/src/api_model.rs +++ b/server/src/api_model.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use rocket::serde::Serialize; #[derive(Serialize, Copy, Clone)] pub enum ReviewState { @@ -68,3 +68,10 @@ pub struct Projects { pub more: bool, pub projects: Vec, } + +#[derive(Serialize)] +pub struct StatusResponse { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..31e18a0 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,207 @@ +use core::net::IpAddr; +use rocket::fairing::AdHoc; +use rocket::form::Form; +use rocket::http::{Cookie, CookieJar, Status}; +use rocket::outcome::{try_outcome, IntoOutcome}; +use rocket::request::{FromRequest, Outcome, Request}; +use rocket::response::status::Unauthorized; +use rocket::serde::json::{self, Json}; +use rocket::serde::{Deserialize, Serialize}; +use rocket::State; +use std::collections::BTreeMap; +use std::sync::Mutex; +use std::time::Instant; +use time::Duration; + +use crate::api_model; + +#[derive(FromForm)] +struct Login<'r> { + username: &'r str, + password: &'r str, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct AuthConfig { + session_max_age_days: u32, +} + +struct SessionsData { + active_ids: BTreeMap, + next_id: u32, +} + +struct Sessions { + data: Mutex, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Session { + pub user_id: u64, + session_id: u32, + remote: String, +} + +#[derive(Debug)] +pub enum SessionError { + Invalid, +} + +const SESSION_COOKIE: &str = "s"; + +fn validate(sessions: &State, session: &Session, request: &Request<'_>) -> bool { + match request.client_ip() { + Some(addr) => { + if session.remote == addr.to_string() { + { + let sessions_data = sessions.data.lock().unwrap(); + match sessions_data.active_ids.get(&session.session_id) { + // We could remove the expired session here, but it will be cleaned + // next time anyone logs in anyway. + Some(&expire) => expire > Instant::now(), + None => false, + } + } + } else { + false + } + } + None => false, + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Session { + type Error = SessionError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let sessions = try_outcome!(request + .guard::<&State>() + .await + .map_error(|_| (Status::Unauthorized, SessionError::Invalid))); + + request + .cookies() + .get_private(SESSION_COOKIE) + .and_then(|cookie| -> Option { json::from_str(cookie.value()).ok() }) + .and_then(|session| { + if validate(&sessions, &session, request) { + Some(session) + } else { + None + } + }) + .or_error((Status::Unauthorized, SessionError::Invalid)) + } +} + +fn new_session( + sessions: &State, + user_id: u64, + remote: String, + max_age: Duration, +) -> Session { + let session_id; + { + let mut sessions_data = sessions.data.lock().unwrap(); + session_id = sessions_data.next_id; + sessions_data.next_id += 1; + + let now = Instant::now(); + // Remove expired sessions first + sessions_data + .active_ids + .retain(|_, &mut expire| expire > now); + + sessions_data.active_ids.insert(session_id, now + max_age); + } + Session { + user_id: user_id, + session_id: session_id, + remote: remote, + } +} + +#[post("/login", data = "")] +fn login( + auth_config: &State, + sessions: &State, + ipaddr: IpAddr, + cookies: &CookieJar<'_>, + login: Form>, +) -> Result, Unauthorized<&'static str>> { + if login.username == "user" && login.password == "password" { + let max_age = Duration::days(i64::from(auth_config.session_max_age_days)); + let session = new_session(&sessions, 1u64, ipaddr.to_string(), max_age); + + let cookie = Cookie::build((SESSION_COOKIE, json::to_string(&session).unwrap())) + .path("/api") + .max_age(max_age) + .http_only(true) + .build(); + + cookies.add_private(cookie); + Ok(Json(api_model::StatusResponse { + ok: true, + error: None, + })) + } else { + Err(Unauthorized("Unknown username or password")) + } +} + +#[get("/logout")] +fn logout( + session: Session, + sessions: &State, + cookies: &CookieJar<'_>, +) -> Json { + { + let mut sessions_data = sessions.data.lock().unwrap(); + sessions_data.active_ids.remove(&session.session_id); + } + + let cookie = Cookie::build((SESSION_COOKIE, "")) + .path("/api") + .http_only(true) + .build(); + + cookies.remove_private(cookie); + + Json(api_model::StatusResponse { + ok: true, + error: None, + }) +} + +#[get("/status")] +fn status(_session: Session) -> Json { + Json(api_model::StatusResponse { + ok: true, + error: None, + }) +} + +#[catch(401)] +fn unauthorized() -> Json { + Json(api_model::StatusResponse { + ok: false, + error: Some("Unauthorized".to_string()), + }) +} + +pub fn stage(basepath: String) -> AdHoc { + AdHoc::on_ignite("Auth Stage", |rocket| async { + rocket + .manage(Sessions { + data: Mutex::new(SessionsData { + active_ids: BTreeMap::new(), + next_id: 1, + }), + }) + .attach(AdHoc::config::()) + .mount(basepath.clone(), routes![login, logout, status]) + .register(basepath, catchers![unauthorized]) + }) +} diff --git a/server/src/main.rs b/server/src/main.rs index f4fec18..223d861 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -4,39 +4,18 @@ extern crate rocket; use futures::{future::TryFutureExt, stream::TryStreamExt}; use rocket::fairing::{self, AdHoc}; -use rocket::request::{self, FromRequest, Outcome, Request}; use rocket::response::status::NotFound; use rocket::serde::json::Json; use rocket::{futures, Build, Rocket}; use rocket_db_pools::{sqlx, Connection, Database}; mod api_model; +mod auth; #[derive(Database)] #[database("eyeballs")] struct Db(sqlx::MySqlPool); -struct User { - username: String, -} - -#[derive(Debug)] -enum UserError { - Missing, - Invalid, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for User { - type Error = UserError; - - async fn from_request(_req: &'r Request<'_>) -> request::Outcome { - Outcome::Success(User { - username: String::from("foo"), - }) - } -} - enum Role { Reviewer, Watcher, @@ -76,7 +55,7 @@ impl TryFrom for api_model::ReviewState { #[get("/projects?&")] async fn projects<'r>( mut db: Connection, - _user: User, + _session: auth::Session, limit: Option, offset: Option, ) -> Json { @@ -117,7 +96,7 @@ async fn projects<'r>( #[get("/project/")] async fn project<'r>( mut db: Connection, - _user: User, + _session: auth::Session, projectid: u64, ) -> Result, NotFound<&'static str>> { let members = sqlx::query!( @@ -154,7 +133,7 @@ async fn project<'r>( #[get("/project//reviews?&")] async fn reviews<'r>( mut db: Connection, - _user: User, + _session: auth::Session, projectid: u64, limit: Option, offset: Option, @@ -204,7 +183,7 @@ async fn reviews<'r>( #[get("/review/")] async fn review<'r>( mut db: Connection, - _user: User, + _session: auth::Session, reviewid: u64, ) -> Result, NotFound<&'static str>> { let mut users = sqlx::query!( @@ -277,10 +256,13 @@ async fn run_migrations(rocket: Rocket) -> fairing::Result { #[rocket::main] async fn main() -> Result<(), rocket::Error> { + let basepath = "/api/v1"; + let _rocket = rocket::build() .attach(Db::init()) .attach(AdHoc::try_on_ignite("Database Migrations", run_migrations)) - .mount("/api/v1", routes![projects, project, reviews, review]) + .mount(basepath, routes![projects, project, reviews, review]) + .attach(auth::stage(basepath.to_string())) .launch() .await?; -- cgit v1.2.3-70-g09d2