diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2024-12-29 20:37:26 +0100 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2024-12-29 20:37:26 +0100 |
| commit | 7bc8e8b7262a3f3abe3222b3b434838e85cdb2bb (patch) | |
| tree | 4f5abb6180a069126cd787310942d5d7f8436768 /server/src | |
| parent | 0aa2545b703f5240a8208a07da8ab20b8bc6d1aa (diff) | |
Rework auth to include session
The actual authentication is still fake.
Diffstat (limited to 'server/src')
| -rw-r--r-- | server/src/api_model.rs | 9 | ||||
| -rw-r--r-- | server/src/auth.rs | 207 | ||||
| -rw-r--r-- | server/src/main.rs | 36 |
3 files changed, 224 insertions, 28 deletions
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<ProjectEntry>, } + +#[derive(Serialize)] +pub struct StatusResponse { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} 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<u32, Instant>, + next_id: u32, +} + +struct Sessions { + data: Mutex<SessionsData>, +} + +#[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<Sessions>, 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<Session, SessionError> { + let sessions = try_outcome!(request + .guard::<&State<Sessions>>() + .await + .map_error(|_| (Status::Unauthorized, SessionError::Invalid))); + + request + .cookies() + .get_private(SESSION_COOKIE) + .and_then(|cookie| -> Option<Session> { 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<Sessions>, + 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 = "<login>")] +fn login( + auth_config: &State<AuthConfig>, + sessions: &State<Sessions>, + ipaddr: IpAddr, + cookies: &CookieJar<'_>, + login: Form<Login<'_>>, +) -> Result<Json<api_model::StatusResponse>, 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<Sessions>, + cookies: &CookieJar<'_>, +) -> Json<api_model::StatusResponse> { + { + 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<api_model::StatusResponse> { + Json(api_model::StatusResponse { + ok: true, + error: None, + }) +} + +#[catch(401)] +fn unauthorized() -> Json<api_model::StatusResponse> { + 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::<AuthConfig>()) + .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<Self, Self::Error> { - Outcome::Success(User { - username: String::from("foo"), - }) - } -} - enum Role { Reviewer, Watcher, @@ -76,7 +55,7 @@ impl TryFrom<u8> for api_model::ReviewState { #[get("/projects?<limit>&<offset>")] async fn projects<'r>( mut db: Connection<Db>, - _user: User, + _session: auth::Session, limit: Option<u32>, offset: Option<u32>, ) -> Json<api_model::Projects> { @@ -117,7 +96,7 @@ async fn projects<'r>( #[get("/project/<projectid>")] async fn project<'r>( mut db: Connection<Db>, - _user: User, + _session: auth::Session, projectid: u64, ) -> Result<Json<api_model::Project>, NotFound<&'static str>> { let members = sqlx::query!( @@ -154,7 +133,7 @@ async fn project<'r>( #[get("/project/<projectid>/reviews?<limit>&<offset>")] async fn reviews<'r>( mut db: Connection<Db>, - _user: User, + _session: auth::Session, projectid: u64, limit: Option<u32>, offset: Option<u32>, @@ -204,7 +183,7 @@ async fn reviews<'r>( #[get("/review/<reviewid>")] async fn review<'r>( mut db: Connection<Db>, - _user: User, + _session: auth::Session, reviewid: u64, ) -> Result<Json<api_model::Review>, NotFound<&'static str>> { let mut users = sqlx::query!( @@ -277,10 +256,13 @@ async fn run_migrations(rocket: Rocket<Build>) -> 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?; |
