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/src/api_model.rs | 9 ++- server/src/auth.rs | 207 ++++++++++++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 36 +++------ 3 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 server/src/auth.rs (limited to 'server/src') 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