diff options
| -rw-r--r-- | .sqlx/query-57f1b5546a9071467b2f78f3384b853dcb23d73ef05398cab4d6767be9fb3d50.json | 12 | ||||
| -rw-r--r-- | Cargo.lock | 5 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/index.rs | 4 | ||||
| -rw-r--r-- | src/login/extract.rs | 69 | ||||
| -rw-r--r-- | src/login/mod.rs | 1 | ||||
| -rw-r--r-- | src/login/repo/tokens.rs | 15 | ||||
| -rw-r--r-- | src/login/routes.rs | 43 |
8 files changed, 136 insertions, 14 deletions
diff --git a/.sqlx/query-57f1b5546a9071467b2f78f3384b853dcb23d73ef05398cab4d6767be9fb3d50.json b/.sqlx/query-57f1b5546a9071467b2f78f3384b853dcb23d73ef05398cab4d6767be9fb3d50.json new file mode 100644 index 0000000..464a475 --- /dev/null +++ b/.sqlx/query-57f1b5546a9071467b2f78f3384b853dcb23d73ef05398cab4d6767be9fb3d50.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete\n from token\n where secret = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "57f1b5546a9071467b2f78f3384b853dcb23d73ef05398cab4d6767be9fb3d50" +} @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.81" +version = "0.1.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" dependencies = [ "proc-macro2", "quote", @@ -707,6 +707,7 @@ name = "hi" version = "0.1.0" dependencies = [ "argon2", + "async-trait", "axum", "axum-extra", "chrono", @@ -5,6 +5,7 @@ edition = "2021" [dependencies] argon2 = "0.5.3" +async-trait = "0.1.82" axum = { version = "0.7.5", features = ["form"] } axum-extra = { version = "0.9.3", features = ["cookie"] } chrono = "0.4.38" 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<S> FromRequestParts<S> for IdentityToken +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + 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<ResponseParts, Self::Error> { + 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<SqlitePool> { - 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<SqlitePool>, - cookies: CookieJar, + identity: IdentityToken, Form(form): Form<Login>, ) -> Result<impl IntoResponse, InternalError> { 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<SqlitePool>, + identity: IdentityToken, +) -> Result<impl IntoResponse, InternalError> { + 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")) +} |
