From 289e99ba977ebe6c4599141bc368c17f9905ffcc Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 3 Sep 2024 20:38:48 -0400 Subject: Add logout support. --- src/index.rs | 4 +++ src/login/extract.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ src/login/mod.rs | 1 + src/login/repo/tokens.rs | 15 +++++++++++ src/login/routes.rs | 43 +++++++++++++++++++++--------- 5 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 src/login/extract.rs (limited to 'src') diff --git a/src/index.rs b/src/index.rs index 6411ff4..98eb47f 100644 --- a/src/index.rs +++ b/src/index.rs @@ -31,6 +31,10 @@ mod templates { } button { "hi" } } + + form action="/logout" method="post" { + button { "bye" } + } } } } diff --git a/src/login/extract.rs b/src/login/extract.rs new file mode 100644 index 0000000..d39e3df --- /dev/null +++ b/src/login/extract.rs @@ -0,0 +1,69 @@ +use std::convert::Infallible; + +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponseParts, ResponseParts}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; + +// The usage pattern here - receive the extractor as an argument, return it in +// the response - is heavily modelled after CookieJar's own intended usage. +pub struct IdentityToken { + cookies: CookieJar, +} + +impl IdentityToken { + /// Get the identity token sent in the request, if any. If the identity was + /// not sent, or if it has previously been [clear]ed, then this will return + /// [None]. If the identity has previously been [set], then this will return + /// that token. + pub fn token(&self) -> Option<&str> { + self.cookies.get(IDENTITY_COOKIE).map(Cookie::value) + } + + /// Positively set the identity token, and ensure that it will be sent back + /// to the client when this extractor is included in a response. + pub fn set(self, token: &str) -> Self { + let identity_cookie = Cookie::build((IDENTITY_COOKIE, String::from(token))) + .http_only(true) + .permanent() + .build(); + + IdentityToken { + cookies: self.cookies.add(identity_cookie), + } + } + + /// Remove the identity token and ensure that it will be cleared when this + /// extractor is included in a response. + pub fn clear(self) -> Self { + IdentityToken { + cookies: self.cookies.remove(IDENTITY_COOKIE), + } + } +} + +const IDENTITY_COOKIE: &str = "identity"; + +#[async_trait::async_trait] +impl FromRequestParts for IdentityToken +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let cookies = CookieJar::from_request_parts(parts, state).await?; + Ok(IdentityToken { cookies }) + } +} + +impl IntoResponseParts for IdentityToken { + type Error = Infallible; + + fn into_response_parts(self, res: ResponseParts) -> Result { + let IdentityToken { cookies } = self; + cookies.into_response_parts(res) + } +} diff --git a/src/login/mod.rs b/src/login/mod.rs index 8769407..4074359 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,4 +1,5 @@ pub use self::routes::router; +mod extract; mod repo; mod routes; diff --git a/src/login/repo/tokens.rs b/src/login/repo/tokens.rs index 080e35a..e31a301 100644 --- a/src/login/repo/tokens.rs +++ b/src/login/repo/tokens.rs @@ -44,4 +44,19 @@ impl<'c> Tokens<'c> { Ok(secret) } + + pub async fn revoke(&mut self, token: &str) -> Result<(), BoxedError> { + sqlx::query!( + r#" + delete + from token + where secret = $1 + "#, + token, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } } diff --git a/src/login/routes.rs b/src/login/routes.rs index c9def2a..a00982d 100644 --- a/src/login/routes.rs +++ b/src/login/routes.rs @@ -5,16 +5,20 @@ use axum::{ routing::post, Router, }; -use axum_extra::extract::cookie::{Cookie, CookieJar}; use chrono::Utc; use sqlx::sqlite::SqlitePool; use crate::error::InternalError; -use super::repo::{logins::Provider as _, tokens::Provider as _}; +use super::{ + extract::IdentityToken, + repo::{logins::Provider as _, tokens::Provider as _}, +}; pub fn router() -> Router { - Router::new().route("/login", post(on_login)) + Router::new() + .route("/login", post(on_login)) + .route("/logout", post(on_logout)) } #[derive(serde::Deserialize)] @@ -25,10 +29,15 @@ struct Login { async fn on_login( State(db): State, - cookies: CookieJar, + identity: IdentityToken, Form(form): Form, ) -> Result { let now = Utc::now(); + + if identity.token().is_some() { + return Ok((StatusCode::BAD_REQUEST, identity, "already logged in")); + } + let mut tx = db.begin().await?; // Spelling the following in the more conventional form, @@ -59,20 +68,30 @@ async fn on_login( tx.commit().await?; let resp = if let Some(token) = token { - let cookie = Cookie::build(("identity", token)) - .http_only(true) - .permanent() - .build(); - let cookies = cookies.add(cookie); - - (StatusCode::OK, cookies, "logged in") + let identity = identity.set(&token); + (StatusCode::OK, identity, "logged in") } else { ( StatusCode::UNAUTHORIZED, - cookies, + identity, "invalid name or password", ) }; Ok(resp) } + +async fn on_logout( + State(db): State, + identity: IdentityToken, +) -> Result { + if let Some(token) = identity.token() { + let mut tx = db.begin().await?; + tx.tokens().revoke(token).await?; + tx.commit().await?; + } + + let identity = identity.clear(); + + Ok((StatusCode::OK, identity, "logged out")) +} -- cgit v1.2.3