From 70b417865f835ce385caab19ce5cb19945f1ae20 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Fri, 25 Aug 2023 10:16:59 +0000 Subject: [PATCH] Implement logout, renaming sessions::Cookie to sessions::Sesssion --- src/routes/login.rs | 4 +- src/routes/logout.rs | 20 +++++ src/routes/mod.rs | 2 + src/state/sessions.rs | 189 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 178 insertions(+), 37 deletions(-) create mode 100644 src/routes/logout.rs diff --git a/src/routes/login.rs b/src/routes/login.rs index 9026d3a..390190f 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -72,8 +72,8 @@ pub async fn route(user: Option, cookies: Cookies, state: State, cookies: Cookies, state: State) -> String { + if let Some(user) = user { + let cookie = user.cookie(); + cookies.remove(Cookie::new(Cow::Owned(cookie.name().to_string()), Cow::Owned(cookie.value().to_string()))); + return format!("Goodbye, {}. You are now logged out.", user.username()); + } + + return format!("You are not logged in."); +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 7f101be..7075864 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,12 +8,14 @@ use crate::state::RoutableAppState; mod index; mod login; +mod logout; /// Build a router for the application, in a specific subpath eg `/yunohost/sso/` pub fn router(subpath: Option, state: RoutableAppState) -> Router { let app = Router::new() .route("/", get(index::route)) .route("/login/", post(login::route)) + .route("/logout/", get(logout::route)) .layer(CookieManagerLayer::new()) .with_state(state); if let Some(p) = subpath { diff --git a/src/state/sessions.rs b/src/state/sessions.rs index 8f476a5..5e08971 100644 --- a/src/state/sessions.rs +++ b/src/state/sessions.rs @@ -5,8 +5,9 @@ use axum::{ }; use ring::{hmac,rand}; use snafu::prelude::*; +use std::borrow::Cow; use tokio::sync::RwLock; -use tower_cookies::Cookies; +use tower_cookies::{Cookies, Cookie}; use yunohost_api::Username; use crate::{ @@ -46,10 +47,10 @@ pub struct SessionManager { pub secret: hmac::Key, /// The list of currently valid cookies. Some of them may have been invalidated, /// but they are stored in a different list to guarantee fast race-free access to this one. - pub cookies: RwLock>, + pub cookies: RwLock>, /// The list of invalidated cookies, due to logout or other purging mechanisms. /// This list is behind a mutex (RwLock) to prevent race conditions - pub invalidated_cookies: RwLock>, + pub invalidated_cookies: RwLock>, /// Expiration duration for set cookies, in seconds pub expiration_secs: u64, @@ -71,14 +72,14 @@ impl SessionManager { }) } - /// Validates that a submitted cookie is a valid session. Returns: + /// Verifies that a submitted cookie is a valid session. Returns: /// - Err(_) if the cookie format is really wrong - /// - Ok(Some(name)) if the user is still logged in - /// - Ok(None) if the user is no longer logged in (invalid/expired cookie) - pub async fn verify_cookie(&self, cookie_claim: &str) -> Result, SessionError> { + /// - Ok(Some(Session)) if the user is still logged in + /// - Ok(None) if the user is no longer lgoged in (invalid/expired cookie) + pub async fn verify_cookie_session(&self, cookie_claim: &str) -> Result, SessionError> { // First check the expiration of the claimed cookie. // If the timestamp was messed with, it will fail further verification. - if let Some(claimed_timestamp) = Cookie::has_expired(cookie_claim, self.expiration_secs)? { + if let Some(claimed_timestamp) = Session::has_expired(cookie_claim, self.expiration_secs)? { // The claimed timestamp is still valid. Check if we ever had that cookie in memory. // If the server was restarted, user will have to login again. if let Some(valid_cookie) = self.find_cookie(&self.cookies, claimed_timestamp, &cookie_claim).await { @@ -88,7 +89,7 @@ impl SessionManager { Ok(None) } else { // User is still logged in! - Ok(Some(valid_cookie.username())) + Ok(Some(valid_cookie)) } } else { // User doesn't have an active session @@ -103,7 +104,7 @@ impl SessionManager { /// Generates a new valid cookie inside the `SessionManager`, and returns: /// - the cookie name to be set /// - the cookie content that can be sent to a client - pub async fn make_session(&self, cookie_name: &str, username: &Username) -> (String, String) { + pub async fn make_session(&self, cookie_name: &str, username: &Username) -> Session { let now = now(); let signable_payload = format!("{now}:{cookie_name}:{username}"); let signed_payload = hmac::sign(&self.secret, signable_payload.as_bytes()).as_ref().to_vec(); @@ -113,7 +114,7 @@ impl SessionManager { hex::encode(&signed_payload), ); - let cookie = Cookie { + let cookie = Session { cookie_name: cookie_name.to_string(), timestamp: now, username: username.clone(), @@ -125,14 +126,30 @@ impl SessionManager { // We don't want to block too long the cookie jar // So it's only locked for this block (then dropped) let mut jar = self.cookies.write().await; - jar.push(cookie); + jar.push(cookie.clone()); } - (cookie_name.to_string(), cookie_payload) + cookie + } + + /// Invalidated a previously-set session cookie + pub async fn invalidate_session(&self, timestamp: i64, content: &str) -> bool { + if let Some(cookie) = self.find_cookie(&self.cookies, timestamp, content).await { + debug!("[SESSION:{}] User {} is logging out", &cookie.cookie_name, &cookie.username); + // Make sure the cookie was not already invalidated to avoid DOSing our invalidated cookie storage + if self.find_cookie(&self.invalidated_cookies, timestamp, content).await.is_none() { + let mut jar = self.invalidated_cookies.write().await; + jar.push(cookie); + return true; + } + } + + // User was not logged in, or session was already invalidated + false } /// Helper method to find a cookie with a specific timestamp, name and username in a cookie jar - async fn find_cookie(&self, jar: &RwLock>, timestamp: i64, content: &str) -> Option { + async fn find_cookie(&self, jar: &RwLock>, timestamp: i64, content: &str) -> Option { jar.read().await.iter().find(|cookie| { // First compare the timestamp (cheapest operation to invalidate the match) cookie.timestamp == timestamp @@ -143,7 +160,7 @@ impl SessionManager { /// A signed/encrypted as stored in memory. #[derive(Clone, Debug)] -pub struct Cookie { +pub struct Session { /// The POSIX timetamp this cookie was created at pub timestamp: i64, /// The cookie name @@ -156,7 +173,7 @@ pub struct Cookie { pub content: String, } -impl Cookie { +impl Session { /// Extrats the timestamp from a stringy cookie pub fn timestamp(cookie: &str) -> Result { let (timestamp, _rest) = cookie.split_once(':') @@ -187,31 +204,61 @@ impl Cookie { } /// The typed [`yunohost_api::Username`] for which this cookie is deemed valid. - pub fn username(&self) -> Username { - self.username.clone() + pub fn username(&self) -> &Username { + &self.username + } + + pub fn cookie(&self) -> Cookie<'static> { + Cookie::build( + Cow::Owned(self.cookie_name.to_string()), + Cow::Owned(self.content.to_string()), + ).path( + Cow::Owned("/".to_string()) + ).finish() } } #[derive(Clone, Debug)] -pub struct LoggedInUser(Username); +pub struct LoggedInUser { + username: Username, + timestamp: i64, +} impl LoggedInUser { - pub fn new(username: Username) -> LoggedInUser { - LoggedInUser(username) + pub fn new(username: Username, timestamp: i64) -> LoggedInUser { + LoggedInUser { + username, + timestamp, + } } pub fn as_str(&self) -> &str { - self.0.as_str() + self.username.as_str() } - pub fn as_username(&self) -> &Username { - &self.0 + pub fn username(&self) -> &Username { + &self.username + } + + pub fn timestamp(&self) -> i64 { + self.timestamp + } + + +} + +impl From for LoggedInUser { + fn from(s: Session) -> LoggedInUser { + LoggedInUser { + username: s.username, + timestamp: s.timestamp, + } } } impl std::fmt::Display for LoggedInUser { fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(fmt) + self.username.fmt(fmt) } } @@ -220,16 +267,88 @@ impl FromRequestParts for LoggedInUser { type Rejection = Error; async fn from_request_parts(parts: &mut Parts, state: &RoutableAppState) -> Result { - // TODO: error let cookies: Cookies = parts.extract().await.map_err(|_| Error::Cookie)?; - trace!("[SESSION:{}] Checking if the user has a valid session", COOKIE_NAME); - if let Some(session_cookie) = cookies.get(COOKIE_NAME) { - trace!("[SESSION:{}] User claims to have a session with cookie: {}", COOKIE_NAME, &session_cookie); - if let Some(username) = state.sessions.verify_cookie(session_cookie.value()).await.context(SessionSnafu)? { - debug!("[SESSION:{}] User {} resumed session.", COOKIE_NAME, &username); - return Ok(LoggedInUser::new(username)); + trace!("[SESSION:{}/login] Checking if the user has a valid session", COOKIE_NAME); + let session_cookie = if let Some(session_cookie) = cookies.get(COOKIE_NAME) { + trace!("[SESSION:{}/login] User claims to have a session with cookie: {}", COOKIE_NAME, &session_cookie); + session_cookie + } else { + trace!("[SESSION:{}/login] No current session cookie", COOKIE_NAME); + return Err(Error::Session { + source: SessionError::NoSession { + cookie_name: COOKIE_NAME.to_string(), + } + }); + }; + + if let Some(session) = state.sessions.verify_cookie_session(session_cookie.value()).await.context(SessionSnafu)? { + debug!("[SESSION:{}/login] User {} resumed session.", COOKIE_NAME, &session.username); + return Ok(session.into()); + } else { + trace!("[SESSION:{}/login] User session is invalid or has expired.", COOKIE_NAME); + return Err(Error::Session { + source: SessionError::InvalidOrExpired { + cookie_name: COOKIE_NAME.to_string(), + } + }); + } + } +} + +pub struct LoggedOutUser(Session); + +impl LoggedOutUser { + pub fn new(session: Session) -> Self { + Self(session) + } + + pub fn username(&self) -> &Username { + self.0.username() + } + + pub fn cookie(&self) -> Cookie { + Cookie::build( + Cow::Owned(self.0.cookie_name.to_string()), + Cow::Owned(self.0.content.to_string()), + ).path( + Cow::Owned("/".to_string()) + ).finish() + } +} + +impl From for LoggedOutUser { + fn from(s: Session) -> LoggedOutUser { + LoggedOutUser(s) + } +} + +#[async_trait] +impl FromRequestParts for LoggedOutUser { + type Rejection = Error; + + async fn from_request_parts(parts: &mut Parts, state: &RoutableAppState) -> Result { + let cookies: Cookies = parts.extract().await.map_err(|_| Error::Cookie)?; + trace!("[SESSION:{}/logout] Checking if the user has a valid session", COOKIE_NAME); + trace!("{:#?}", parts.headers); + let session_cookie = if let Some(session_cookie) = cookies.get(COOKIE_NAME) { + trace!("[SESSION:{}/logout] User claims to have a session with cookie: {}", COOKIE_NAME, &session_cookie); + session_cookie + } else { + trace!("[SESSION:{}/logout] No current session cookie", COOKIE_NAME); + return Err(Error::Session { + source: SessionError::NoSession { + cookie_name: COOKIE_NAME.to_string(), + } + }); + }; + + if let Some(session) = state.sessions.verify_cookie_session(session_cookie.value()).await.context(SessionSnafu)? { + debug!("[SESSION:{}/logout] User {} resumed session, requesting to log out.", COOKIE_NAME, &session.username); + if state.sessions.invalidate_session(session.timestamp, &session.content).await { + return Ok(session.into()); } else { - trace!("[SESSION:{}] User session is invalid or has expired.", COOKIE_NAME); + warn!("[SESSION:{}/logout] User {} requested logout but was already logged out. Here's the session:\n{:#?}", COOKIE_NAME, &session.username, &session); + warn!("This is probably a race condition but is completely harmless."); return Err(Error::Session { source: SessionError::InvalidOrExpired { cookie_name: COOKIE_NAME.to_string(), @@ -237,9 +356,9 @@ impl FromRequestParts for LoggedInUser { }); } } else { - trace!("[SESSION:{}] No current session cookie", COOKIE_NAME); + trace!("[SESSION:{}/logout] User session is invalid or has expired.", COOKIE_NAME); return Err(Error::Session { - source: SessionError::NoSession { + source: SessionError::InvalidOrExpired { cookie_name: COOKIE_NAME.to_string(), } });