diff --git a/src/auth/http.rs b/src/auth/http.rs index bf151c6..13481b3 100644 --- a/src/auth/http.rs +++ b/src/auth/http.rs @@ -2,7 +2,11 @@ use anyhow::Result; use axum::{ async_trait, extract::{FromRequestParts, State}, - http::{header::COOKIE, request::Parts, HeaderValue, Request, StatusCode}, + http::{ + header::{COOKIE, SET_COOKIE}, + request::Parts, + HeaderValue, Request, StatusCode, + }, middleware::Next, response::Response, Extension, @@ -45,6 +49,23 @@ where } } +pub struct RequireSession(pub Session); + +#[async_trait] +impl FromRequestParts for RequireSession +where + S: Send + Sync, +{ + type Rejection = AppError<'static>; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match Extension::::from_request_parts(parts, state).await { + Ok(Extension(session)) => Ok(RequireSession(session)), + _ => Err(INVALID_SESSION), + } + } +} + pub async fn refresh_sessions( State(state): State>, mut req: Request, @@ -69,8 +90,22 @@ pub async fn refresh_sessions( .ok() { let extensions = req.extensions_mut(); - extensions.insert(session); + extensions.insert(session.clone()); extensions.insert(user); + + let mut response = next.run(req).await; + // Only set the session cookie if it hasn't been set yet (eg. logout) + let headers = response.headers_mut(); + if !headers.contains_key(SET_COOKIE) { + headers.insert( + SET_COOKIE, + session + .cookie(state.config.domain().as_str(), state.config.secure()) + .parse() + .unwrap(), + ); + } + return response; } } } diff --git a/src/auth/session.rs b/src/auth/session.rs index 4f05e01..44818a4 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -144,6 +144,13 @@ impl Session { } } + pub async fn destroy(self: Self, pool: &Pool) -> Result<()> { + sqlx::query!("DELETE FROM sessions WHERE id = $1", self.id) + .execute(pool) + .await?; + Ok(()) + } + pub async fn prune_dead(pool: &Pool) -> Result { let now = Utc::now().naive_utc(); let result = sqlx::query!("DELETE FROM sessions WHERE expires_at < $1", now) @@ -158,6 +165,21 @@ impl Session { .secure(secure) .http_only(!secure) .path("/") + .expires( + cookie::time::OffsetDateTime::from_unix_timestamp(self.expires_at.timestamp()) + .unwrap(), + ) + .finish() + .to_string() + } + + pub fn cookie_for_delete(domain: &str, secure: bool) -> String { + Cookie::build("session", "") + .domain(domain) + .secure(secure) + .http_only(!secure) + .path("/") + .max_age(cookie::time::Duration::seconds(0)) .finish() .to_string() } diff --git a/src/main.rs b/src/main.rs index fa0e93f..699629a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ async fn main() -> Result<()> { let app = Router::new() .route("/auth/login", post(routes::auth::login)) + .route("/auth/logout", post(routes::auth::logout)) .route("/me", get(routes::auth::me)) .route("/pages/:site/:slug", get(routes::content::page)) .route("/admin/bootstrap", post(routes::admin::bootstrap)) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 4dd006a..69b8807 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -2,7 +2,7 @@ use axum::{ extract::State, http::{header::SET_COOKIE, StatusCode}, response::{IntoResponse, Response}, - Extension, Json, + Json, }; use chrono::Duration; use serde::Deserialize; @@ -10,7 +10,12 @@ use serde_json::json; use std::sync::Arc; use crate::{ - auth::{hash::verify, http::RequireUser, session::Session, user::User}, + auth::{ + hash::verify, + http::{RequireSession, RequireUser}, + session::Session, + user::User, + }, error::AppError, state::AppState, }; @@ -56,12 +61,10 @@ pub async fn login( let mut response: Response = Json(json!({ "session_token": token, "expires_at": session.expires_at })).into_response(); - let secure = state.config.secure(); - response.headers_mut().insert( SET_COOKIE, session - .cookie(state.config.domain().as_str(), secure) + .cookie(state.config.domain().as_str(), state.config.secure()) .parse()?, ); @@ -71,3 +74,18 @@ pub async fn login( pub async fn me(RequireUser(user): RequireUser) -> Result> { Ok(user.name) } + +pub async fn logout( + State(state): State>, + RequireSession(session): RequireSession, +) -> Result> { + session.destroy(&state.database).await?; + + let mut response: Response = Json(json!({ "ok": true })).into_response(); + response.headers_mut().insert( + SET_COOKIE, + Session::cookie_for_delete(state.config.domain().as_str(), state.config.secure()) + .parse()?, + ); + Ok(response) +}