From f7818bcb1b393869faab95fbba2ef5166f81d191 Mon Sep 17 00:00:00 2001 From: Luc Date: Tue, 30 Jul 2024 13:21:56 +0000 Subject: [PATCH] Upgrade Auth & Sessions --- engine/Cargo.lock | 31 ++--- engine/Cargo.toml | 26 +++- engine/migrations/0002_sessions.sql | 4 +- engine/src/auth/middleware.rs | 12 +- engine/src/auth/session.rs | 65 ++++----- engine/src/models/mod.rs | 6 +- engine/src/models/user_data.rs | 31 ++++- engine/src/routes/auth.rs | 153 --------------------- engine/src/routes/me.rs | 29 ++++ engine/src/routes/mod.rs | 55 ++------ engine/src/routes/oauth/callback.rs | 72 ++++++++++ engine/src/routes/oauth/login.rs | 44 ++++++ engine/src/routes/oauth/mod.rs | 3 + engine/src/routes/sessions/delete.rs | 26 ++++ engine/src/routes/sessions/mod.rs | 32 +++++ engine/tests/.gitkeep | 0 web/package.json | 1 + web/pnpm-lock.yaml | 4 +- web/src/api/me.ts | 49 +++---- web/src/components/ActiveSessionsTable.tsx | 39 ++++-- web/src/components/Navbar.tsx | 80 ++++++----- 21 files changed, 425 insertions(+), 337 deletions(-) delete mode 100644 engine/src/routes/auth.rs create mode 100644 engine/src/routes/me.rs create mode 100644 engine/src/routes/oauth/callback.rs create mode 100644 engine/src/routes/oauth/login.rs create mode 100644 engine/src/routes/oauth/mod.rs create mode 100644 engine/src/routes/sessions/delete.rs create mode 100644 engine/src/routes/sessions/mod.rs create mode 100644 engine/tests/.gitkeep diff --git a/engine/Cargo.lock b/engine/Cargo.lock index a4c988c..fec03a4 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -1830,8 +1830,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "poem" version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1ba1c27f8f89e1bccdda0c680f72790545a11a8d8555819472f5839d7a8ca9d" +source = "git+https://github.com/poem-web/poem?branch=master#9d32cecbad6f6e25a659ffe46a432a174340fbfc" dependencies = [ "bytes", "chrono", @@ -1851,7 +1850,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "poem-derive", - "quick-xml 0.36.1", + "quick-xml", "regex", "rfc7239", "serde", @@ -1873,8 +1872,7 @@ dependencies = [ [[package]] name = "poem-derive" version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62fea1692d80a000126f9b28d865012a160b80000abb53ccf152b428222c155" +source = "git+https://github.com/poem-web/poem?branch=master#9d32cecbad6f6e25a659ffe46a432a174340fbfc" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1885,8 +1883,7 @@ dependencies = [ [[package]] name = "poem-openapi" version = "5.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6f4ac688d8b83fbbc8de929dee207d345600a6b3af315927e92dba26a023103" +source = "git+https://github.com/poem-web/poem?branch=master#9d32cecbad6f6e25a659ffe46a432a174340fbfc" dependencies = [ "base64 0.22.1", "bytes", @@ -1899,22 +1896,23 @@ dependencies = [ "num-traits", "poem", "poem-openapi-derive", - "quick-xml 0.32.0", + "quick-xml", "regex", "serde", "serde_json", "serde_urlencoded", "serde_yaml", + "sqlx", "thiserror", "tokio", + "url", "uuid", ] [[package]] name = "poem-openapi-derive" version = "5.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0812a8f13ac63020d1274d80ea2229e858ed0118a2d9537744465995e0913375" +source = "git+https://github.com/poem-web/poem?branch=master#9d32cecbad6f6e25a659ffe46a432a174340fbfc" dependencies = [ "darling", "http", @@ -2025,16 +2023,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.36.1" @@ -3326,6 +3314,8 @@ dependencies = [ "color-eyre", "dotenv", "dotenvy", + "hex", + "hmac", "openid", "poem", "poem-openapi", @@ -3333,6 +3323,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2", "sqlx", "terminal-banner", "tracing", diff --git a/engine/Cargo.toml b/engine/Cargo.toml index cff20b7..f709031 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -12,14 +12,34 @@ chrono = "0.4.38" color-eyre = "0.6.3" dotenv = "0.15.0" dotenvy = "0.15.7" +hex = "0.4.3" +hmac = "0.12.1" openid = "0.14.0" -poem = "3.0.4" -poem-openapi = { version = "5.0.3", features = ["chrono", "uuid", "email", "email_address", "redoc", "static-files"] } +poem = { version = "3.0.4", git = "https://github.com/poem-web/poem", branch = "master" } +poem-openapi = { version = "5", git = "https://github.com/poem-web/poem", branch = "master", features = [ + "chrono", + "uuid", + "sqlx", + "url", + "email", + "email_address", + "redoc", + "static-files", +] } reqwest = "0.12.5" serde = "1.0.204" serde_json = "1.0.120" serde_with = { version = "3.9.0", features = ["json", "chrono"] } -sqlx = { version = "0.7.4", features = ["runtime-async-std", "tls-rustls", "postgres", "uuid", "chrono", "json", "ipnetwork"] } +sha2 = "0.10.8" +sqlx = { version = "0.7.4", features = [ + "runtime-async-std", + "tls-rustls", + "postgres", + "uuid", + "chrono", + "json", + "ipnetwork", +] } terminal-banner = "0.4.1" tracing = "0.1.40" tracing-subscriber = "0.3.18" diff --git a/engine/migrations/0002_sessions.sql b/engine/migrations/0002_sessions.sql index cf6e3b0..a36be8c 100644 --- a/engine/migrations/0002_sessions.sql +++ b/engine/migrations/0002_sessions.sql @@ -1,8 +1,8 @@ CREATE TABLE IF NOT EXISTS sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id TEXT PRIMARY KEY NOT NULL, user_id INT NOT NULL, - user_agent VARCHAR(255) NOT NULL, + user_agent TEXT NOT NULL, user_ip INET NOT NULL, last_access TIMESTAMPTZ NOT NULL DEFAULT NOW(), valid BOOLEAN NOT NULL DEFAULT TRUE diff --git a/engine/src/auth/middleware.rs b/engine/src/auth/middleware.rs index 5fd44cf..0301c6b 100644 --- a/engine/src/auth/middleware.rs +++ b/engine/src/auth/middleware.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use hmac::{Hmac, Mac}; use poem::{web::Data, Error, FromRequest, Request, RequestBody, Result}; use reqwest::StatusCode; -use uuid::Uuid; +use sha2::Sha256; use crate::state::AppState; @@ -26,12 +27,17 @@ impl<'a> FromRequest<'a> for AuthToken { .headers() .get("Authorization") .and_then(|x| x.to_str().ok()) - .and_then(|x| Uuid::parse_str(&x.replace("Bearer ", "")).ok()); + .map(|x| x.replace("Bearer ", "")); match token { Some(token) => { + // Hash the token + let mut hash = Hmac::::new_from_slice(b"").unwrap(); + hash.update(token.as_bytes()); + let hash = hex::encode(hash.finalize().into_bytes()); + // Check if active session exists with token - let session = SessionState::get_by_id(token, &state.database) + let session = SessionState::try_access(&hash, &state.database) .await .unwrap() .ok_or(Error::from_string( diff --git a/engine/src/auth/session.rs b/engine/src/auth/session.rs index 68ca649..89b17cb 100644 --- a/engine/src/auth/session.rs +++ b/engine/src/auth/session.rs @@ -3,23 +3,12 @@ use std::net::IpAddr; use poem_openapi::Object; use serde::{Deserialize, Serialize}; use sqlx::types::chrono; -use uuid::Uuid; use crate::database::Database; -#[derive(Debug, Clone, Serialize, Deserialize, Object)] -pub struct SafeSession { - pub id: String, - pub user_id: i32, - pub user_agent: String, - pub user_ip: IpAddr, - pub last_access: chrono::DateTime, - pub valid: bool, -} - -#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Object)] pub struct SessionState { - pub id: Uuid, + pub id: String, pub user_id: i32, pub user_agent: String, pub user_ip: IpAddr, @@ -29,14 +18,16 @@ pub struct SessionState { impl SessionState { pub async fn new( + session_id: &str, user_id: i32, user_agent: &str, user_ip: &IpAddr, database: &Database, ) -> Result { let session = sqlx::query_as::<_, SessionState>( - "INSERT INTO sessions (user_id, user_agent, user_ip) VALUES ($1, $2, $3) RETURNING *", + "INSERT INTO sessions (id, user_id, user_agent, user_ip) VALUES ($1, $2, $3, $4) RETURNING *", ) + .bind(session_id) .bind(user_id) .bind(user_agent) .bind(user_ip) @@ -45,7 +36,7 @@ impl SessionState { Ok(session) } - pub async fn get_by_id(id: Uuid, database: &Database) -> Result, sqlx::Error> { + pub async fn get_by_id(id: &str, database: &Database) -> Result, sqlx::Error> { let session = sqlx::query_as::<_, SessionState>( "SELECT * FROM sessions WHERE id = $1 AND valid = TRUE", ) @@ -56,8 +47,22 @@ impl SessionState { Ok(session) } + pub async fn try_access(id: &str, database: &Database) -> Result, sqlx::Error> { + let session = sqlx::query_as::<_, SessionState>( + "UPDATE sessions SET last_access = NOW() WHERE id = $1 AND valid = TRUE RETURNING *", + ) + .bind(id) + .fetch_optional(&database.pool) + .await?; + + Ok(session) + } + /// Get all sessions for a user that are valid - pub async fn get_by_user_id(user_id: i32, database: &Database) -> Result, sqlx::Error> { + pub async fn get_by_user_id( + user_id: i32, + database: &Database, + ) -> Result, sqlx::Error> { let sessions = sqlx::query_as::<_, SessionState>( "SELECT * FROM sessions WHERE user_id = $1 AND valid = TRUE", ) @@ -69,7 +74,10 @@ impl SessionState { } /// Set every session to invalid - pub async fn invalidate_by_user_id(user_id: i32, database: &Database) -> Result, sqlx::Error> { + pub async fn invalidate_by_user_id( + user_id: i32, + database: &Database, + ) -> Result, sqlx::Error> { let sessions = sqlx::query_as::<_, SessionState>( "UPDATE sessions SET valid = FALSE WHERE user_id = $1", ) @@ -81,7 +89,11 @@ impl SessionState { } /// Invalidate all sessions for a user that are older than the given time - pub async fn invalidate_by_user_id_by_time(user_id: i32, database: &Database, invalidate_before: chrono::DateTime) -> Result, sqlx::Error> { + pub async fn _invalidate_by_user_id_by_time( + user_id: i32, + database: &Database, + _invalidate_before: chrono::DateTime, + ) -> Result, sqlx::Error> { let sessions = sqlx::query_as::<_, SessionState>( "UPDATE sessions SET valid = FALSE WHERE user_id = $1 AND last_access < $2", ) @@ -92,20 +104,3 @@ impl SessionState { Ok(sessions) } } - -impl Into for SessionState { - fn into(self) -> SafeSession { - let id = self.id.to_string(); - let id = id[0..6].to_string() + &id[30..]; - - SafeSession { - // Strip uuid to be abc...xyz - id, - user_id: self.user_id, - user_agent: self.user_agent, - user_ip: self.user_ip, - last_access: self.last_access, - valid: self.valid, - } - } -} \ No newline at end of file diff --git a/engine/src/models/mod.rs b/engine/src/models/mod.rs index d5862bc..fe44354 100644 --- a/engine/src/models/mod.rs +++ b/engine/src/models/mod.rs @@ -1,4 +1,4 @@ -pub mod user_data; -pub mod property; -pub mod product; pub mod media; +pub mod product; +pub mod property; +pub mod user_data; diff --git a/engine/src/models/user_data.rs b/engine/src/models/user_data.rs index 28c060c..6bc909e 100644 --- a/engine/src/models/user_data.rs +++ b/engine/src/models/user_data.rs @@ -1,23 +1,33 @@ use openid::Userinfo; +use poem_openapi::Object; use serde::{Deserialize, Serialize}; use sqlx::types::Json; use tracing::info; +use url::Url; use crate::database::Database; +#[derive(Debug, Clone, Serialize, Deserialize, Object)] +pub struct User { + pub id: i32, + pub oauth_sub: String, + pub name: String, + pub picture: Option, +} + #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] -pub struct UserData { +pub struct UserEntry { pub id: i32, pub oauth_sub: String, pub oauth_data: Json, pub nickname: Option, } -impl UserData { +impl UserEntry { pub async fn new(oauth_userinfo: &Userinfo, database: &Database) -> Result { let sub = oauth_userinfo.sub.as_deref().unwrap(); - info!("Initializing new User {:?}", oauth_userinfo); + info!("Initializing new User {:?}", oauth_userinfo.sub); sqlx::query_as::<_, Self>( "INSERT INTO users (oauth_sub, oauth_data) VALUES ($1, $2) ON CONFLICT (oauth_sub) DO UPDATE SET oauth_data = $2 RETURNING *" @@ -36,3 +46,18 @@ impl UserData { Ok(user) } } + +impl From for User { + fn from(user: UserEntry) -> Self { + Self { + id: user.id, + oauth_sub: user.oauth_sub, + name: user + .nickname + .or(user.oauth_data.nickname.clone()) + .or(user.oauth_data.name.clone()) + .unwrap_or("Unknown".to_string()), + picture: user.oauth_data.picture.clone(), + } + } +} diff --git a/engine/src/routes/auth.rs b/engine/src/routes/auth.rs deleted file mode 100644 index b2c52c0..0000000 --- a/engine/src/routes/auth.rs +++ /dev/null @@ -1,153 +0,0 @@ -use crate::{ - auth::{middleware::AuthToken, session::SessionState}, - models::user_data::UserData, - state::AppState, -}; -use openid::{Options, Prompt, Token}; -use poem::{ - handler, - http::HeaderMap, - web::{Data, Json, Query, RealIp, Redirect}, - Error, IntoResponse, -}; -use reqwest::StatusCode; -use serde::Deserialize; -use std::{collections::HashSet, sync::Arc}; -use url::Url; - -#[derive(Deserialize)] -struct LoginQuery { - redirect: Option, -} - -#[handler] -pub async fn login(query: Query, state: Data<&Arc>) -> impl IntoResponse { - // let discovery_url = "http://localhost:8080/realms/master/.well-known/openid-configuration"; - - // let http_client = reqwest::Client::new(); - // let discovery_response: DiscoveryResponse = http_client - // .get(discovery_url) - // .send() - // .await.unwrap() - // .json() - // .await.unwrap(); - - // scopes, for calendar for example https://www.googleapis.com/auth/calendar.events - let scope = "openid email profile".to_string(); - - let options = Options { - scope: Some(scope), - state: query.redirect.clone(), - prompt: Some(HashSet::from([Prompt::SelectAccount])), - ..Default::default() - }; - - // Generate the authorization URL - let authorize_url = state.openid.auth_url(&options); - - println!("OpenID Connect Authorization URL: {}", authorize_url); - - // redirect to the authorization URL - Redirect::temporary(authorize_url.as_str()) -} - -#[derive(Deserialize)] -pub struct MyQuery { - pub state: Option, - pub scope: Option, - pub hd: Option, - pub authuser: Option, - pub code: String, - pub prompt: Option, -} - -#[handler] -pub async fn callback( - query: Query, - state: Data<&Arc>, - ip: RealIp, - headers: &HeaderMap, -) -> impl IntoResponse { - let mut token = state.openid.request_token(&query.code).await.unwrap(); - - let mut token = Token::from(token); - - let mut id_token = token.id_token.take().unwrap(); - - state.openid.decode_token(&mut id_token).unwrap(); - state.openid.validate_token(&id_token, None, None).unwrap(); - - let oauth_userinfo = state.openid.request_userinfo(&token).await.unwrap(); - - format!("Hello {:?}", oauth_userinfo); - - // Now we must verify the user information, decide wether they deserve access, and if so return a token. - let user = UserData::new(&oauth_userinfo, &state.database) - .await - .unwrap(); - - let user_agent = headers.get("user-agent").unwrap().to_str().unwrap(); - let user_ip = ip.0.unwrap(); - - let session = SessionState::new(user.id, user_agent, &user_ip, &state.database) - .await - .unwrap(); - - let token = session.id; - - let mut redirect_url: Url = query - .state - .clone() - .unwrap_or("http://localhost:3000/me".to_string()) - .parse() - .unwrap(); - - redirect_url.set_query(Some(&format!("token={}", token))); - - Redirect::temporary(redirect_url) - .with_header("Set-Cookie", format!("property.v3x.token={}", token)) -} - -#[handler] -pub async fn me(state: Data<&Arc>, token: AuthToken) -> impl IntoResponse { - match token { - AuthToken::Active(active_user) => { - let user = UserData::get_by_id(active_user.session.user_id, &state.database) - .await - .unwrap(); - - Json(user).into_response() - } - _ => Error::from_string("Not Authenticated", StatusCode::UNAUTHORIZED).into_response(), - } -} - -#[handler] -pub async fn get_sessions(state: Data<&Arc>, token: AuthToken) -> impl IntoResponse { - match token { - AuthToken::Active(active_user) => { - let sessions = - SessionState::get_by_user_id(active_user.session.user_id, &state.database) - .await - .unwrap(); - - Json(sessions).into_response() - } - _ => Error::from_string("Not Authenticated", StatusCode::UNAUTHORIZED).into_response(), - } -} - -#[handler] -pub async fn delete_sessions(state: Data<&Arc>, token: AuthToken) -> impl IntoResponse { - match token { - AuthToken::Active(active_user) => { - let sessions = - SessionState::invalidate_by_user_id(active_user.session.user_id, &state.database) - .await - .unwrap(); - - Json(sessions).into_response() - } - _ => Error::from_string("Not Authenticated", StatusCode::UNAUTHORIZED).into_response(), - } -} diff --git a/engine/src/routes/me.rs b/engine/src/routes/me.rs new file mode 100644 index 0000000..394fb1f --- /dev/null +++ b/engine/src/routes/me.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use poem::{handler, web::Data, Error, IntoResponse}; +use poem_openapi::{payload::Json, OpenApi}; +use reqwest::StatusCode; + +use crate::{auth::middleware::AuthToken, models::user_data::{User, UserEntry}, state::AppState}; + +pub struct ApiMe; + +#[OpenApi] +impl ApiMe { + #[oai(path = "/me", method = "get")] + pub async fn me(&self, state: Data<&Arc>, token: AuthToken) -> Json { + match token { + AuthToken::Active(active_user) => { + let user = UserEntry::get_by_id(active_user.session.user_id, &state.database) + .await + .unwrap(); + + Json(user.into()) + } + _ => { + // Error::from_string("Not Authenticated", StatusCode::UNAUTHORIZED).into_response(), + panic!() + } + } + } +} diff --git a/engine/src/routes/mod.rs b/engine/src/routes/mod.rs index c0940d9..ba06e85 100644 --- a/engine/src/routes/mod.rs +++ b/engine/src/routes/mod.rs @@ -1,25 +1,25 @@ use std::sync::Arc; +use me::ApiMe; use poem::{ - get, handler, + delete, get, handler, listener::TcpListener, - middleware::{CookieJarManager, Cors}, + middleware::Cors, web::{Data, Html, Path}, EndpointExt, Route, Server, }; use poem_openapi::{param::Query, payload::PlainText, OpenApi, OpenApiService}; -use reqwest::StatusCode; +use sessions::ApiSessions; use crate::{ - auth::{ - middleware::AuthToken, - session::{SafeSession, SessionState}, - }, + auth::{middleware::AuthToken, session::SessionState}, models::{media::Media, product::Product, property::Property}, state::AppState, }; -pub mod auth; +pub mod me; +pub mod oauth; +pub mod sessions; struct Api; @@ -85,28 +85,6 @@ impl Api { poem_openapi::payload::Json(property) } - - #[oai(path = "/sessions", method = "get")] - async fn get_sessions( - &self, - auth: AuthToken, - state: Data<&Arc>, - ) -> poem_openapi::payload::Json> { - match auth { - AuthToken::Active(active_user) => { - let sessions = - SessionState::get_by_user_id(active_user.session.user_id, &state.database) - .await - .unwrap() - .into_iter() - .map(|x| x.into()) - .collect(); - - poem_openapi::payload::Json(sessions) - } - _ => poem_openapi::payload::Json(vec![]), - } - } } // returns the html from the index.html file @@ -116,25 +94,22 @@ async fn ui() -> Html<&'static str> { } pub async fn serve(state: AppState) -> Result<(), poem::Error> { - let api_service = - OpenApiService::new(Api, "Hello World", "1.0").server("http://localhost:3000/api"); + let api_service = OpenApiService::new((Api, ApiMe, ApiSessions), "Hello World", "1.0") + .server("http://localhost:3000/api"); let spec = api_service.spec_endpoint(); let state = Arc::new(state); let app = Route::new() - .at("/login", get(auth::login)) - .at("/me", get(auth::me)) - .at( - "/sessions", - get(auth::get_sessions).delete(auth::delete_sessions), - ) - .at("/callback", get(auth::callback)) + // .at("/login", get(auth::login)) + // .at("/me", get(auth::me)) + // .at("/sessions", delete(auth::delete_sessions)) + // .at("/callback", get(auth::callback)) .nest("/api", api_service) .nest("/openapi.json", spec) .at("/", get(ui)) - .with(CookieJarManager::new()) + // .with(CookieJarManager::new()) .with(Cors::new()) .data(state); diff --git a/engine/src/routes/oauth/callback.rs b/engine/src/routes/oauth/callback.rs new file mode 100644 index 0000000..ddf40dc --- /dev/null +++ b/engine/src/routes/oauth/callback.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use hmac::{Hmac, Mac}; +use openid::Token; +use poem::{handler, http::HeaderMap, web::{Data, Query, RealIp, Redirect}, IntoResponse}; +use serde::Deserialize; +use sha2::Sha256; +use url::Url; +use uuid::Uuid; + +use crate::{auth::session::SessionState, models::user_data::UserEntry, state::AppState}; + + +#[derive(Deserialize, Debug)] +pub struct CallbackQuery { + pub state: Option, + pub scope: Option, + pub hd: Option, + pub authuser: Option, + pub code: String, + pub prompt: Option, +} + +#[handler] +pub async fn callback( + query: Query, + state: Data<&Arc>, + ip: RealIp, + headers: &HeaderMap, +) -> impl IntoResponse { + let mut token = state.openid.request_token(&query.code).await.unwrap(); + + let mut token = Token::from(token); + + let mut id_token = token.id_token.take().unwrap(); + + state.openid.decode_token(&mut id_token).unwrap(); + state.openid.validate_token(&id_token, None, None).unwrap(); + + let oauth_userinfo = state.openid.request_userinfo(&token).await.unwrap(); + + format!("Hello {:?}", oauth_userinfo); + + // Now we must verify the user information, decide wether they deserve access, and if so return a token. + let user = UserEntry::new(&oauth_userinfo, &state.database) + .await + .unwrap(); + + let user_agent = headers.get("user-agent").unwrap().to_str().unwrap(); + let user_ip = ip.0.unwrap(); + + let token = Uuid::new_v4().to_string(); + let mut hash = Hmac::::new_from_slice(b"").unwrap(); + hash.update(token.as_bytes()); + let hash = hex::encode(hash.finalize().into_bytes()); + + let session = SessionState::new(&hash, user.id, user_agent, &user_ip, &state.database) + .await + .unwrap(); + + let mut redirect_url: Url = query + .state + .clone() + .unwrap_or("http://localhost:3000/me".to_string()) + .parse() + .unwrap(); + + redirect_url.set_query(Some(&format!("token={}", token))); + + Redirect::temporary(redirect_url) + .with_header("Set-Cookie", format!("property.v3x.token={}", token)) +} diff --git a/engine/src/routes/oauth/login.rs b/engine/src/routes/oauth/login.rs new file mode 100644 index 0000000..ee7bc96 --- /dev/null +++ b/engine/src/routes/oauth/login.rs @@ -0,0 +1,44 @@ +use std::{collections::HashSet, sync::Arc}; + +use openid::{Options, Prompt}; +use poem::{handler, web::{Data, Query, Redirect}, IntoResponse}; +use serde::Deserialize; + +use crate::state::AppState; + + +#[derive(Deserialize)] +struct LoginQuery { + redirect: Option, +} + +#[handler] +pub async fn login(query: Query, state: Data<&Arc>) -> impl IntoResponse { + // let discovery_url = "http://localhost:8080/realms/master/.well-known/openid-configuration"; + + // let http_client = reqwest::Client::new(); + // let discovery_response: DiscoveryResponse = http_client + // .get(discovery_url) + // .send() + // .await.unwrap() + // .json() + // .await.unwrap(); + + // scopes, for calendar for example https://www.googleapis.com/auth/calendar.events + let scope = "openid email profile".to_string(); + + let options = Options { + scope: Some(scope), + state: query.redirect.clone(), + prompt: Some(HashSet::from([Prompt::SelectAccount])), + ..Default::default() + }; + + // Generate the authorization URL + let authorize_url = state.openid.auth_url(&options); + + println!("OpenID Connect Authorization URL: {}", authorize_url); + + // redirect to the authorization URL + Redirect::temporary(authorize_url.as_str()) +} diff --git a/engine/src/routes/oauth/mod.rs b/engine/src/routes/oauth/mod.rs new file mode 100644 index 0000000..2cafcf0 --- /dev/null +++ b/engine/src/routes/oauth/mod.rs @@ -0,0 +1,3 @@ + +pub mod callback; +pub mod login; diff --git a/engine/src/routes/sessions/delete.rs b/engine/src/routes/sessions/delete.rs new file mode 100644 index 0000000..5e7f9ac --- /dev/null +++ b/engine/src/routes/sessions/delete.rs @@ -0,0 +1,26 @@ +use crate::{ + auth::{middleware::AuthToken, session::SessionState}, + state::AppState, +}; +use poem::{ + handler, + web::{Data, Json}, + Error, IntoResponse, +}; +use reqwest::StatusCode; +use std::sync::Arc; + +#[handler] +pub async fn delete_sessions(state: Data<&Arc>, token: AuthToken) -> impl IntoResponse { + match token { + AuthToken::Active(active_user) => { + let sessions = + SessionState::invalidate_by_user_id(active_user.session.user_id, &state.database) + .await + .unwrap(); + + Json(sessions).into_response() + } + _ => Error::from_string("Not Authenticated", StatusCode::UNAUTHORIZED).into_response(), + } +} diff --git a/engine/src/routes/sessions/mod.rs b/engine/src/routes/sessions/mod.rs new file mode 100644 index 0000000..9c51f94 --- /dev/null +++ b/engine/src/routes/sessions/mod.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use poem::web::Data; +use poem_openapi::OpenApi; + +use crate::{ + auth::{middleware::AuthToken, session::SessionState}, + state::AppState, +}; + +pub mod delete; + +pub struct ApiSessions; + +#[OpenApi] +impl ApiSessions { + #[oai(path = "/sessions", method = "get")] + async fn get_sessions( + &self, + auth: AuthToken, + state: Data<&Arc>, + ) -> poem_openapi::payload::Json> { + match auth { + AuthToken::Active(active_user) => poem_openapi::payload::Json( + SessionState::get_by_user_id(active_user.session.user_id, &state.database) + .await + .unwrap(), + ), + AuthToken::None => poem_openapi::payload::Json(vec![]), + } + } +} diff --git a/engine/tests/.gitkeep b/engine/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/package.json b/web/package.json index 0c0c5f1..562c21a 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@tanstack/react-router": "^1.45.14", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", + "clsx": "^2.1.1", "eslint-plugin-unused-imports": "^4.0.1", "globals": "^15.8.0", "postcss": "^8.4.38", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1cdc87a..a9e68b2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: autoprefixer: specifier: ^10.4.19 version: 10.4.19(postcss@8.4.38) + clsx: + specifier: ^2.1.1 + version: 2.1.1 eslint-plugin-unused-imports: specifier: ^4.0.1 version: 4.0.1(eslint@8.57.0) @@ -1319,7 +1322,6 @@ packages: /clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - dev: true /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} diff --git a/web/src/api/me.ts b/web/src/api/me.ts index f5478d2..933d445 100644 --- a/web/src/api/me.ts +++ b/web/src/api/me.ts @@ -3,29 +3,30 @@ import { useHttp } from './core'; export type ApiMeResponse = { id: number; oauth_sub: string; - oauth_data: { - sub: string; - name: string; - given_name: string; - family_name: string; - middle_name: null; - nickname: null; - preferred_username: null; - profile: null; - picture: string; - website: null; - email: string; - email_verified: boolean; - gender: null; - birthdate: null; - zoneinfo: null; - locale: null; - phone_number: null; - phone_number_verified: boolean; - address: null; - updated_at: null; - }; - nickname: any; + name: string; + picture: string; + // oauth_data: { + // sub: string; + // name: string; + // given_name: string; + // family_name: string; + // middle_name: null; + // nickname: null; + // preferred_username: null; + // profile: null; + // picture: string; + // website: null; + // email: string; + // email_verified: boolean; + // gender: null; + // birthdate: null; + // zoneinfo: null; + // locale: null; + // phone_number: null; + // phone_number_verified: boolean; + // address: null; + // updated_at: null; + // }; }; -export const useApiMe = () => useHttp('/me'); +export const useApiMe = () => useHttp('/api/me'); diff --git a/web/src/components/ActiveSessionsTable.tsx b/web/src/components/ActiveSessionsTable.tsx index 9e80f5d..fb09e3e 100644 --- a/web/src/components/ActiveSessionsTable.tsx +++ b/web/src/components/ActiveSessionsTable.tsx @@ -1,3 +1,4 @@ +import { clsx } from 'clsx'; import { FC } from 'react'; import { UAParser } from 'ua-parser-js'; @@ -18,21 +19,28 @@ export const ActiveSessionsTable: FC = () => {
{sessions && sessions.map((session) => { - const ua = UAParser(session.user_agent); - const time = new Date(session.last_access); - const x = getRelativeTimeString(time); + const user_agent = UAParser(session.user_agent); + const last_accessed = new Date(session.last_access); + const last_accessed_formatted = + getRelativeTimeString(last_accessed); + const isRecent = + last_accessed.getTime() > + Date.now() - 1000 * 60 * 60 * 24; return (
+
+ {session.user_ip} +
{[ - ua.browser.name, - ua.browser.version, + user_agent.browser.name, + user_agent.browser.version, ] .filter(Boolean) .join(' ')} @@ -40,17 +48,24 @@ export const ActiveSessionsTable: FC = () => { on {[ - ua.os.name, - ua.cpu.architecture, - ua.os.version, + user_agent.os.name, + user_agent.cpu.architecture, + user_agent.os.version, ] .filter(Boolean) .join(' ')}
-
{session.user_ip}
-
{x}
-
#{session.id}
+
+ {last_accessed_formatted} +
+
+ #{session.id.slice(0, 6)} +
diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index 36ecf2c..c48b87e 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -1,14 +1,18 @@ -import { useApiMe } from "../api/me"; -import { useAuth } from "../api/auth"; -import { Link } from "@tanstack/react-router"; +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable no-undef */ +import { Link } from '@tanstack/react-router'; -const LOGIN_URL = "http://localhost:3000/login"; +import { useAuth } from '../api/auth'; +import { useApiMe } from '../api/me'; + +const LOGIN_URL = 'http://localhost:3000/login'; export const Navbar = () => { const { token, clearAuthToken } = useAuth(); const { data: meData, isLoading: isVerifyingAuth } = useApiMe(); - const login_here_url = LOGIN_URL + "?redirect=" + encodeURIComponent(window.location.href); + const login_here_url = + LOGIN_URL + '?redirect=' + encodeURIComponent(window.location.href); console.log({ meData }); @@ -19,52 +23,52 @@ export const Navbar = () => { v3x.property
- { - [ - ["/", "Home"], - ["/sessions", "Sessions"], - ].map(([path, name]) => ( - - {name} - - )) - } + {[ + ['/', 'Home'], + ['/sessions', 'Sessions'], + ].map(([path, name]) => ( + + {name} + + ))}
- { - meData &&
+ {meData && ( +
- +
-
{meData.oauth_data?.name}
+
{meData.name}
-
- Hello -
-
- } - { - !token && - + )} + {!token && ( + Login - } - {/*
-
-
- -
-
Luc van Kampen
-
-
*/} + )}
); };