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! --- ...55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json | 20 ------- ...ab71cc7531cc00eac44eda227cb0321d1d417f975e.json | 26 ++++++++ ...b42d3fa9460e220c662d83c8af6e3412a317076e4d.json | 26 -------- ...5932f00e82f9dd4ad8a5abf3e905db36d3f9a06712.json | 32 ++++++++++ ...6617e402396d761dc5c1d3ffde85e5f8fe6cce5a6b.json | 26 ++++++++ src/index.rs | 54 +++++++++++------ 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 ++++++++- 13 files changed, 297 insertions(+), 148 deletions(-) delete mode 100644 .sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json create mode 100644 .sqlx/query-65d3e876d3a51752ff63a5ab71cc7531cc00eac44eda227cb0321d1d417f975e.json delete mode 100644 .sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json create mode 100644 .sqlx/query-a9203ebdb9c57e59ae79ae5932f00e82f9dd4ad8a5abf3e905db36d3f9a06712.json create mode 100644 .sqlx/query-c0b5b8401995ea7ae491946617e402396d761dc5c1d3ffde85e5f8fe6cce5a6b.json 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 diff --git a/.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json b/.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json deleted file mode 100644 index 91a1e49..0000000 --- a/.sqlx/query-07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert or fail\n into login (id, name, password_hash)\n values ($1, $2, $3)\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false - ] - }, - "hash": "07dc90365168e34f2640bb55c2ca8b868804d4dfcf5d4bdb55d0db9ba883f7a8" -} diff --git a/.sqlx/query-65d3e876d3a51752ff63a5ab71cc7531cc00eac44eda227cb0321d1d417f975e.json b/.sqlx/query-65d3e876d3a51752ff63a5ab71cc7531cc00eac44eda227cb0321d1d417f975e.json new file mode 100644 index 0000000..8011996 --- /dev/null +++ b/.sqlx/query-65d3e876d3a51752ff63a5ab71cc7531cc00eac44eda227cb0321d1d417f975e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n select\n login.id as \"id: LoginId\",\n name\n from login\n join token on login.id = token.login\n where token.secret = $1\n ", + "describe": { + "columns": [ + { + "name": "id: LoginId", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "65d3e876d3a51752ff63a5ab71cc7531cc00eac44eda227cb0321d1d417f975e" +} diff --git a/.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json b/.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json deleted file mode 100644 index a4dffb5..0000000 --- a/.sqlx/query-67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n password_hash as \"password_hash: StoredHash\"\n from login\n where name = $1\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "password_hash: StoredHash", - "ordinal": 1, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false - ] - }, - "hash": "67a4bc5f758852e19c50fab42d3fa9460e220c662d83c8af6e3412a317076e4d" -} diff --git a/.sqlx/query-a9203ebdb9c57e59ae79ae5932f00e82f9dd4ad8a5abf3e905db36d3f9a06712.json b/.sqlx/query-a9203ebdb9c57e59ae79ae5932f00e82f9dd4ad8a5abf3e905db36d3f9a06712.json new file mode 100644 index 0000000..8c6e9f2 --- /dev/null +++ b/.sqlx/query-a9203ebdb9c57e59ae79ae5932f00e82f9dd4ad8a5abf3e905db36d3f9a06712.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name,\n password_hash as \"password_hash: StoredHash\"\n from login\n where name = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "password_hash: StoredHash", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a9203ebdb9c57e59ae79ae5932f00e82f9dd4ad8a5abf3e905db36d3f9a06712" +} diff --git a/.sqlx/query-c0b5b8401995ea7ae491946617e402396d761dc5c1d3ffde85e5f8fe6cce5a6b.json b/.sqlx/query-c0b5b8401995ea7ae491946617e402396d761dc5c1d3ffde85e5f8fe6cce5a6b.json new file mode 100644 index 0000000..66aefac --- /dev/null +++ b/.sqlx/query-c0b5b8401995ea7ae491946617e402396d761dc5c1d3ffde85e5f8fe6cce5a6b.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n insert or fail\n into login (id, name, password_hash)\n values ($1, $2, $3)\n returning id as \"id: Id\", name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false + ] + }, + "hash": "c0b5b8401995ea7ae491946617e402396d761dc5c1d3ffde85e5f8fe6cce5a6b" +} diff --git a/src/index.rs b/src/index.rs index 98eb47f..a716af2 100644 --- a/src/index.rs +++ b/src/index.rs @@ -1,40 +1,56 @@ use axum::{response::IntoResponse, routing::get, Router}; +use sqlx::sqlite::SqlitePool; -pub fn router() -> Router -where - S: Send + Sync + Clone + 'static, -{ +use crate::login::repo::logins::Login; + +pub fn router() -> Router { Router::new().route("/", get(index)) } -async fn index() -> impl IntoResponse { - templates::index() +async fn index(login: Option) -> impl IntoResponse { + templates::index(login) } mod templates { use maud::{html, Markup, DOCTYPE}; - pub fn index() -> Markup { + + use crate::login::repo::logins::Login; + + pub fn index(login: Option) -> Markup { html! { (DOCTYPE) head { title { "hi" } } body { - form action="/login" method="post" { - label { - "name" - input name="name" type="text" {} - } - label { - "password" - input name="password" type="password" {} - } - button { "hi" } + @match login { + None => { (login_form()) } + Some(login) => { (logout_form(&login.name)) } } + } + } + } - form action="/logout" method="post" { - button { "bye" } + fn login_form() -> Markup { + html! { + form action="/login" method="post" { + label { + "name" + input name="name" type="text" {} } + label { + "password" + input name="password" type="password" {} + } + button { "hi" } + } + } + } + + fn logout_form(name: &str) -> Markup { + html! { + form action="/logout" method="post" { + button { "bye, " (name) } } } } 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