From 2965a788cfcf4a0386cb8832e0d96491bf54c1d3 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 4 Sep 2024 00:28:35 -0400 Subject: Display a different / page depending on whether the current identity is valid or not. This is mostly a proof of concept for the implementation of form login implemented in previous commits, but it _is_ useful as it controls whether the / page shows login, or shows logout. From here, chat is next! --- src/login/extract.rs | 69 ------------------------------------- src/login/extract/identity_token.rs | 69 +++++++++++++++++++++++++++++++++++++ src/login/extract/login.rs | 61 ++++++++++++++++++++++++++++++++ src/login/extract/mod.rs | 4 +++ src/login/mod.rs | 2 +- src/login/repo/logins.rs | 27 +++++++++------ src/login/repo/tokens.rs | 29 ++++++++++++++-- 7 files changed, 178 insertions(+), 83 deletions(-) delete mode 100644 src/login/extract.rs create mode 100644 src/login/extract/identity_token.rs create mode 100644 src/login/extract/login.rs create mode 100644 src/login/extract/mod.rs (limited to 'src/login') diff --git a/src/login/extract.rs b/src/login/extract.rs deleted file mode 100644 index d39e3df..0000000 --- a/src/login/extract.rs +++ /dev/null @@ -1,69 +0,0 @@ -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/extract/identity_token.rs b/src/login/extract/identity_token.rs new file mode 100644 index 0000000..d39e3df --- /dev/null +++ b/src/login/extract/identity_token.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/extract/login.rs b/src/login/extract/login.rs new file mode 100644 index 0000000..f49933a --- /dev/null +++ b/src/login/extract/login.rs @@ -0,0 +1,61 @@ +use axum::{ + extract::{FromRequestParts, State}, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, +}; +use sqlx::sqlite::SqlitePool; + +use crate::{ + error::InternalError, + login::{ + extract::IdentityToken, + repo::{logins::Login, tokens::Provider as _}, + }, +}; + +#[async_trait::async_trait] +impl FromRequestParts for Login { + type Rejection = LoginError; + + async fn from_request_parts( + parts: &mut Parts, + state: &SqlitePool, + ) -> Result { + let identity_token = IdentityToken::from_request_parts(parts, state).await?; + + let token = identity_token.token().ok_or(LoginError::Forbidden)?; + + let db = State::::from_request_parts(parts, state).await?; + let mut tx = db.begin().await?; + let login = tx.tokens().validate(token).await?; + tx.commit().await?; + + login.ok_or(LoginError::Forbidden) + } +} + +pub enum LoginError { + Failure(E), + Forbidden, +} + +impl IntoResponse for LoginError +where + E: IntoResponse, +{ + fn into_response(self) -> Response { + match self { + Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden").into_response(), + Self::Failure(e) => e.into_response(), + } + } +} + +impl From for LoginError +where + E: Into, +{ + fn from(err: E) -> Self { + Self::Failure(err.into()) + } +} diff --git a/src/login/extract/mod.rs b/src/login/extract/mod.rs new file mode 100644 index 0000000..ba943a6 --- /dev/null +++ b/src/login/extract/mod.rs @@ -0,0 +1,4 @@ +mod identity_token; +mod login; + +pub use self::identity_token::IdentityToken; diff --git a/src/login/mod.rs b/src/login/mod.rs index 4074359..c2b2924 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,5 +1,5 @@ pub use self::routes::router; mod extract; -mod repo; +pub mod repo; mod routes; diff --git a/src/login/repo/logins.rs b/src/login/repo/logins.rs index c6db86e..84b4bf8 100644 --- a/src/login/repo/logins.rs +++ b/src/login/repo/logins.rs @@ -16,19 +16,17 @@ impl<'c> Provider for Transaction<'c, Sqlite> { } } +// This also implements FromRequestParts (see `src/login/extract/login.rs`). As +// a result, it can be used as an extractor. pub struct Logins<'t>(&'t mut SqliteConnection); #[derive(Debug)] pub struct Login { pub id: Id, - // Field unused (as of this writing), omitted to avoid warnings. - // Feel free to add it: - // - // pub name: String, - - // However, the omission of the hashed password is deliberate, to minimize - // the chance that it ends up tangled up in debug output or in some other - // chunk of logic elsewhere. + pub name: String, + // The omission of the hashed password is deliberate, to minimize the + // chance that it ends up tangled up in debug output or in some other chunk + // of logic elsewhere. } impl<'c> Logins<'c> { @@ -49,7 +47,7 @@ impl<'c> Logins<'c> { insert or fail into login (id, name, password_hash) values ($1, $2, $3) - returning id as "id: Id" + returning id as "id: Id", name "#, id, name, @@ -107,13 +105,22 @@ impl<'c> Logins<'c> { r#" select id as "id: Id", + name, password_hash as "password_hash: StoredHash" from login where name = $1 "#, name, ) - .map(|rec| (Login { id: rec.id }, rec.password_hash)) + .map(|rec| { + ( + Login { + id: rec.id, + name: rec.name, + }, + rec.password_hash, + ) + }) .fetch_optional(&mut *self.0) .await?; diff --git a/src/login/repo/tokens.rs b/src/login/repo/tokens.rs index e31a301..584f6dc 100644 --- a/src/login/repo/tokens.rs +++ b/src/login/repo/tokens.rs @@ -1,7 +1,7 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use uuid::Uuid; -use super::logins::Id as LoginId; +use super::logins::{Id as LoginId, Login}; use crate::error::BoxedError; type DateTime = chrono::DateTime; @@ -45,18 +45,41 @@ impl<'c> Tokens<'c> { Ok(secret) } - pub async fn revoke(&mut self, token: &str) -> Result<(), BoxedError> { + /// Revoke a token by its secret. If there is no such token with that + /// secret, this will succeed by doing nothing. + pub async fn revoke(&mut self, secret: &str) -> Result<(), BoxedError> { sqlx::query!( r#" delete from token where secret = $1 "#, - token, + secret, ) .execute(&mut *self.0) .await?; Ok(()) } + + /// Validate a token by its secret, retrieving the associated Login record. + /// Will return [None] if the token is not valid. + pub async fn validate(&mut self, secret: &str) -> Result, BoxedError> { + let login = sqlx::query_as!( + Login, + r#" + select + login.id as "id: LoginId", + name + from login + join token on login.id = token.login + where token.secret = $1 + "#, + secret, + ) + .fetch_optional(&mut *self.0) + .await?; + + Ok(login) + } } -- cgit v1.2.3