diff --git a/src/store.rs b/src/store.rs index c6d0f50..0bd917f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -356,6 +356,21 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { })) } + /// Given the login session's ID, destroy the session. + pub async fn destroy_login_session(&mut self, login_session_id: i64) -> eyre::Result<()> { + sqlx::query!( + " + DELETE FROM login_sessions + WHERE login_session_id = $1 + ", + login_session_id + ) + .execute(&mut **self.txn) + .await + .context("failed to destroy login session")?; + Ok(()) + } + /// Given an access token's hash, looks up the corresponding application session. pub async fn lookup_application_session( &mut self, diff --git a/src/web.rs b/src/web.rs index 09edf6a..a51dcdf 100644 --- a/src/web.rs +++ b/src/web.rs @@ -51,9 +51,12 @@ use crate::{ }, }; -pub mod ambient; +use self::logout::{get_logout, post_logout}; + +pub(crate) mod ambient; pub mod errors; pub mod login; +pub mod logout; pub mod oauth_openid; pub mod sessionless_xsrf; @@ -150,6 +153,17 @@ pub(crate) async fn make_router( HeaderValue::from_static("DENY"), )), ) + .route( + // CORS NOT to be made available on this endpoint! + // Block loading this in a frame! + "/logout", + get(get_logout) + .post(post_logout) + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + )), + ) .route( "/oidc/token", post(oidc_token).layer(CorsLayer::permissive().max_age(Duration::from_secs(600))), diff --git a/src/web/login.rs b/src/web/login.rs index 14dc6d9..9f25feb 100644 --- a/src/web/login.rs +++ b/src/web/login.rs @@ -135,6 +135,8 @@ pub const LOGIN_SESSION_TOKEN_HASH_BYTES: usize = 32; /// e.g. perhaps the persona should be a hash of the user's UUID? pub const LOGIN_SESSION_XSRF_SECRET_BYTES: usize = 32; +pub const LOGIN_SESSION_COOKIE_NAME: &str = "__Host-LoginSession"; + /// Represents a login session, which is effectively just a 'web UI' session for a user. pub struct LoginSession { /// The system name of the user who is logged in to the idCoop web UI @@ -231,7 +233,7 @@ where else { return Err((StatusCode::UNAUTHORIZED, "No login session.")); }; - let Some(cookie_val) = cookies.get("__Host-LoginSession").map(str::to_owned) else { + let Some(cookie_val) = cookies.get(LOGIN_SESSION_COOKIE_NAME).map(str::to_owned) else { return Err((StatusCode::UNAUTHORIZED, "No login session.")); }; let Ok(login_session_token) = BASE64_URL_SAFE_NO_PAD.decode(&cookie_val) else { @@ -496,7 +498,7 @@ pub async fn post_login( .context("failed to store session in database")?; cookies.add( - Cookie::build(("__Host-LoginSession", login_session_token_b64.clone())) + Cookie::build((LOGIN_SESSION_COOKIE_NAME, login_session_token_b64.clone())) .path("/") .http_only(true) .secure(true) diff --git a/src/web/logout.rs b/src/web/logout.rs new file mode 100644 index 0000000..78b3f4d --- /dev/null +++ b/src/web/logout.rs @@ -0,0 +1,88 @@ +//! logout: let users log out + +use std::sync::Arc; + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Extension, +}; +use hornbeam::render_template_string; +use tower_cookies::{Cookie, Cookies}; + +use crate::store::IdCoopStore; + +use super::{ + ambient::Ambient, + login::{LoginSession, LOGIN_SESSION_COOKIE_NAME}, + sessionless_xsrf, DesiredLocale, Rendered, WebResult, TEMPLATING, +}; + +/// `GET /logout` +/// +/// If logged in, show a button to send a POST request to log out. +/// +/// If not logged in, shows a success message. +pub async fn get_logout(ambient: Ambient, DesiredLocale(locale): DesiredLocale) -> Response { + if ambient.user.is_some() { + // display logout button + Rendered(render_template_string!(TEMPLATING, logout_ask, locale, { + ambient + })) + .into_response() + } else { + // display success message + Rendered(render_template_string!( + TEMPLATING, + logout_success, + locale, + { ambient } + )) + .into_response() + } +} + +/// `POST /logout` +/// +/// If logged in, destroy session and show success message. +/// +/// If not logged in, show success message. +/// +/// We don't care about any specific form data. +/// +/// We don't require an XSRF secret, because SameSite=Lax will protect us +/// in modern browsers. +/// In other browsers, being logged out is not a huge deal; it doesn't seem to be +/// a particularly interesting risk or attack vector, therefore. +pub async fn post_logout( + current_session: Result, + mut ambient: Ambient, + cookies: Cookies, + Extension(store): Extension>, + DesiredLocale(locale): DesiredLocale, +) -> WebResult { + if let Ok(session) = current_session { + store + .txn(|mut txn| { + Box::pin(async move { txn.destroy_login_session(session.login_session_id).await }) + }) + .await?; + + // Clear out cookies + for cookie_name in &[LOGIN_SESSION_COOKIE_NAME, sessionless_xsrf::COOKIE_NAME] { + if let Some(cookie) = cookies.get(cookie_name).map(Cookie::into_owned) { + cookies.remove(cookie); + } + } + + ambient.user = None; + } + + Ok(Rendered(render_template_string!( + TEMPLATING, + logout_success, + locale, + { ambient } + )) + .into_response()) +} diff --git a/templates/pages/logout_ask.hnb b/templates/pages/logout_ask.hnb new file mode 100644 index 0000000..13db164 --- /dev/null +++ b/templates/pages/logout_ask.hnb @@ -0,0 +1,15 @@ +CentredPage {$ambient} + :title + @logout_ask_title + + :main + h1 + @logout_ask_title + + form {method = "POST"} + article + @logout_ask_main + + fieldset.grid + button {type = "submit"} + @logout_ask_logout diff --git a/templates/pages/logout_success.hnb b/templates/pages/logout_success.hnb new file mode 100644 index 0000000..be73468 --- /dev/null +++ b/templates/pages/logout_success.hnb @@ -0,0 +1,10 @@ +CentredPage {$ambient} + :title + @logout_success_title + + :main + h1 + @logout_success_title + + article + @logout_success_main diff --git a/translations/en/login.ftl b/translations/en/login.ftl index adffb81..07b015e 100644 --- a/translations/en/login.ftl +++ b/translations/en/login.ftl @@ -29,3 +29,11 @@ consent_deny = Deny #consent_warn_info = Your username, user ID, display name and avatar will be sent to the service. consent_warn_info = Your username and user ID will be sent to the service. + + +logout_ask_title = Log out? +logout_ask_logout = Confirm log out +logout_ask_main = Would you like to log out? + +logout_success_title = Logged out! +logout_success_main = Successfully logged out. See you again soon!