From 7a00a389fa8b984c948b44e5771f36633693becc Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Fri, 05 Nov 2021 01:30:32 +0000 Subject: [PATCH] Allow the user to look at active sessions for their account Implements the majority of the remaining work required for #17 --- chartered-db/src/users.rs | 22 ++++++++++++++++++++++ chartered-frontend/src/dark.sass | 3 +++ chartered-frontend/src/index.tsx | 4 ++++ chartered-frontend/src/pages/ErrorPage.tsx | 2 +- chartered-frontend/src/sections/Nav.tsx | 4 ++++ chartered-web/src/middleware/logging.rs | 4 ++-- chartered-frontend/src/pages/sessions/ListSessions.tsx | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/web_api/mod.rs | 2 ++ chartered-web/src/endpoints/web_api/sessions/list.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/web_api/sessions/mod.rs | 7 +++++++ 10 files changed, 202 insertions(+), 3 deletions(-) diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs index e0866e8..63530e7 100644 --- a/chartered-db/src/users.rs +++ a/chartered-db/src/users.rs @@ -369,6 +369,28 @@ .await? } + pub async fn list( + conn: ConnectionPool, + given_user_id: i32, + ) -> Result)>> { + use crate::schema::user_sessions::dsl::{expires_at, user_id}; + + tokio::task::spawn_blocking(move || { + let conn = conn.get()?; + + Ok(crate::schema::user_sessions::table + .filter( + expires_at + .is_null() + .or(expires_at.gt(chrono::Utc::now().naive_utc())), + ) + .filter(user_id.eq(given_user_id)) + .left_join(user_ssh_keys::table) + .load(&conn)?) + }) + .await? + } + pub async fn delete(self: Arc, conn: ConnectionPool) -> Result { tokio::task::spawn_blocking(move || { let conn = conn.get()?; diff --git a/chartered-frontend/src/dark.sass b/chartered-frontend/src/dark.sass index 7cd524e..f0669bf 100644 --- a/chartered-frontend/src/dark.sass +++ a/chartered-frontend/src/dark.sass @@ -46,6 +46,9 @@ table td color: $body-color !important + table th + color: $primary !important + a.btn color: $link-color diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx index ecb7b11..9cf29b5 100644 --- a/chartered-frontend/src/index.tsx +++ a/chartered-frontend/src/index.tsx @@ -27,6 +27,7 @@ import Search from "./pages/Search"; import { backgroundFix } from "./overscrollColourFixer"; import Register from "./pages/Register"; +import ListSessions from "./pages/sessions/ListSessions"; if ( window.matchMedia && @@ -72,6 +73,9 @@ } />} /> } />} /> } />} /> + + } />} /> + } />} /> diff --git a/chartered-frontend/src/pages/ErrorPage.tsx b/chartered-frontend/src/pages/ErrorPage.tsx index 12d7aeb..a08b5b4 100644 --- a/chartered-frontend/src/pages/ErrorPage.tsx +++ a/chartered-frontend/src/pages/ErrorPage.tsx @@ -1,6 +1,6 @@ export default function ErrorPage({ message }: { message: string }) { return ( -
+
{message}
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx index eda5ed5..340d380 100644 --- a/chartered-frontend/src/sections/Nav.tsx +++ a/chartered-frontend/src/sections/Nav.tsx @@ -99,6 +99,10 @@ Your profile + + Sessions + + ) -> Self::Future { + fn call(&mut self, req: Request) -> Self::Future { // best practice is to clone the inner service like this // see https://github.com/tower-rs/tower/issues/547 for details let clone = self.0.clone(); @@ -49,7 +49,7 @@ Box::pin(async move { let start = std::time::Instant::now(); - let user_agent = req.headers_mut().remove(axum::http::header::USER_AGENT); + let user_agent = req.headers().get(axum::http::header::USER_AGENT).cloned(); let method = req.method().clone(); let uri = replace_sensitive_path(req.uri().path()); diff --git a/chartered-frontend/src/pages/sessions/ListSessions.tsx b/chartered-frontend/src/pages/sessions/ListSessions.tsx new file mode 100644 index 0000000..2124747 100644 --- /dev/null +++ a/chartered-frontend/src/pages/sessions/ListSessions.tsx @@ -1,0 +1,102 @@ +import Nav from "../../sections/Nav"; +import { useAuth } from "../../useAuth"; +import { Link, Navigate } from "react-router-dom"; +import { RoundedPicture, useAuthenticatedRequest } from "../../util"; +import ErrorPage from "../ErrorPage"; +import { LoadingSpinner } from "../Loading"; +import { OverlayTrigger, Tooltip } from "react-bootstrap"; +import HumanTime from "react-human-time"; + +interface Response { + sessions: ResponseSession[]; +} + +interface ResponseSession { + expires_at: string | null; + user_agent: string | null; + ip: string | null; + ssh_key_fingerprint: string | null; +} + +export default function ListSessions() { + const auth = useAuth(); + + if (!auth) { + return ; + } + + const { response: list, error } = useAuthenticatedRequest({ + auth, + endpoint: "sessions", + }); + + if (error) { + return ; + } + + return ( +
+
+ ); +} diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs index c3ea305..08450e8 100644 --- a/chartered-web/src/endpoints/web_api/mod.rs +++ a/chartered-web/src/endpoints/web_api/mod.rs @@ -1,6 +1,7 @@ mod auth; mod crates; mod organisations; +mod sessions; mod ssh_key; mod users; @@ -15,6 +16,7 @@ .nest("/crates", crates::routes()) .nest("/users", users::routes()) .nest("/auth", auth::authenticated_routes()) + .nest("/sessions", sessions::routes()) .route( "/ssh-key", get(ssh_key::handle_get).put(ssh_key::handle_put), diff --git a/chartered-web/src/endpoints/web_api/sessions/list.rs b/chartered-web/src/endpoints/web_api/sessions/list.rs new file mode 100644 index 0000000..ec482da 100644 --- /dev/null +++ a/chartered-web/src/endpoints/web_api/sessions/list.rs @@ -1,0 +1,55 @@ +use axum::{extract, Json}; +use chartered_db::users::UserSession; +use chartered_db::{users::User, ConnectionPool}; +use serde::Serialize; +use std::sync::Arc; +use thiserror::Error; + +pub async fn handle_get( + extract::Extension(db): extract::Extension, + extract::Extension(user): extract::Extension>, +) -> Result, Error> { + let sessions = UserSession::list(db.clone(), user.id).await?; + + Ok(Json(Response { + sessions: sessions + .into_iter() + .map(|(session, ssh_key)| ResponseSession { + expires_at: session.expires_at, + user_agent: session.user_agent, + ip: session.ip, + ssh_key_fingerprint: ssh_key + .map(|v| v.fingerprint().unwrap_or_else(|_| "INVALID".to_string())), + }) + .collect(), + })) +} + +#[derive(Serialize)] +pub struct Response { + sessions: Vec, +} + +#[derive(Serialize)] +pub struct ResponseSession { + expires_at: Option, + user_agent: Option, + ip: Option, + ssh_key_fingerprint: Option, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("{0}")] + Database(#[from] chartered_db::Error), +} + +impl Error { + pub fn status_code(&self) -> axum::http::StatusCode { + match self { + Self::Database(e) => e.status_code(), + } + } +} + +define_error_response!(Error); diff --git a/chartered-web/src/endpoints/web_api/sessions/mod.rs b/chartered-web/src/endpoints/web_api/sessions/mod.rs new file mode 100644 index 0000000..35ebad4 100644 --- /dev/null +++ a/chartered-web/src/endpoints/web_api/sessions/mod.rs @@ -1,0 +1,7 @@ +mod list; + +use axum::{routing::get, Router}; + +pub fn routes() -> Router { + Router::new().route("/", get(list::handle_get)) +} -- rgit 0.1.3