diff options
| author | Kit La Touche <kit@transneptune.net> | 2024-10-03 23:30:42 -0400 |
|---|---|---|
| committer | Kit La Touche <kit@transneptune.net> | 2024-10-03 23:30:42 -0400 |
| commit | d50b1b56c011c03c7d8a95242af404b727e91a80 (patch) | |
| tree | efe3408f6a8ef669981826d1a29d16a24b460d89 /src/token | |
| parent | 30c13478d61065a512f5bc8824fecbf2ee6afc81 (diff) | |
| parent | 7f12fd41c2941a55a6437f24e4f780104a718790 (diff) | |
Merge branch 'main' into feature-frontend
Diffstat (limited to 'src/token')
| -rw-r--r-- | src/token/app.rs | 170 | ||||
| -rw-r--r-- | src/token/broadcaster.rs | 4 | ||||
| -rw-r--r-- | src/token/event.rs | 12 | ||||
| -rw-r--r-- | src/token/extract/identity.rs | 75 | ||||
| -rw-r--r-- | src/token/extract/identity_token.rs | 94 | ||||
| -rw-r--r-- | src/token/extract/mod.rs | 4 | ||||
| -rw-r--r-- | src/token/id.rs | 27 | ||||
| -rw-r--r-- | src/token/mod.rs | 9 | ||||
| -rw-r--r-- | src/token/repo/auth.rs | 50 | ||||
| -rw-r--r-- | src/token/repo/mod.rs | 4 | ||||
| -rw-r--r-- | src/token/repo/token.rs | 151 | ||||
| -rw-r--r-- | src/token/secret.rs | 27 |
12 files changed, 627 insertions, 0 deletions
diff --git a/src/token/app.rs b/src/token/app.rs new file mode 100644 index 0000000..5c4fcd5 --- /dev/null +++ b/src/token/app.rs @@ -0,0 +1,170 @@ +use chrono::TimeDelta; +use futures::{ + future, + stream::{self, StreamExt as _}, + Stream, +}; +use sqlx::sqlite::SqlitePool; + +use super::{ + broadcaster::Broadcaster, event, repo::auth::Provider as _, repo::Provider as _, Id, Secret, +}; +use crate::{ + clock::DateTime, + db::NotFound as _, + login::{repo::Provider as _, Login, Password}, +}; + +pub struct Tokens<'a> { + db: &'a SqlitePool, + tokens: &'a Broadcaster, +} + +impl<'a> Tokens<'a> { + pub const fn new(db: &'a SqlitePool, tokens: &'a Broadcaster) -> Self { + Self { db, tokens } + } + pub async fn login( + &self, + name: &str, + password: &Password, + login_at: &DateTime, + ) -> Result<Secret, LoginError> { + let mut tx = self.db.begin().await?; + + let login = if let Some((login, stored_hash)) = tx.auth().for_name(name).await? { + if stored_hash.verify(password)? { + // Password verified; use the login. + login + } else { + // Password NOT verified. + return Err(LoginError::Rejected); + } + } else { + let password_hash = password.hash()?; + tx.logins().create(name, &password_hash).await? + }; + + let token = tx.tokens().issue(&login, login_at).await?; + tx.commit().await?; + + Ok(token) + } + + pub async fn validate( + &self, + secret: &Secret, + used_at: &DateTime, + ) -> Result<(Id, Login), ValidateError> { + let mut tx = self.db.begin().await?; + let login = tx + .tokens() + .validate(secret, used_at) + .await + .not_found(|| ValidateError::InvalidToken)?; + tx.commit().await?; + + Ok(login) + } + + pub async fn limit_stream<E>( + &self, + token: Id, + events: impl Stream<Item = E> + std::fmt::Debug, + ) -> Result<impl Stream<Item = E> + std::fmt::Debug, ValidateError> + where + E: std::fmt::Debug, + { + // Subscribe, first. + let token_events = self.tokens.subscribe(); + + // Check that the token is valid at this point in time, second. If it is, then + // any future revocations will appear in the subscription. If not, bail now. + // + // It's possible, otherwise, to get to this point with a token that _was_ valid + // at the start of the request, but which was invalided _before_ the + // `subscribe()` call. In that case, the corresponding revocation event will + // simply be missed, since the `token_events` stream subscribed after the fact. + // This check cancels guarding the stream here. + // + // Yes, this is a weird niche edge case. Most things don't double-check, because + // they aren't expected to run long enough for the token's revocation to + // matter. Supervising a stream, on the other hand, will run for a + // _long_ time; if we miss the race here, we'll never actually carry out the + // supervision. + let mut tx = self.db.begin().await?; + tx.tokens() + .require(&token) + .await + .not_found(|| ValidateError::InvalidToken)?; + tx.commit().await?; + + // Then construct the guarded stream. First, project both streams into + // `GuardedEvent`. + let token_events = token_events + .filter(move |event| future::ready(event.token == token)) + .map(|_| GuardedEvent::TokenRevoked); + let events = events.map(|event| GuardedEvent::Event(event)); + + // Merge the two streams, then unproject them, stopping at + // `GuardedEvent::TokenRevoked`. + let stream = stream::select(token_events, events).scan((), |(), event| { + future::ready(match event { + GuardedEvent::Event(event) => Some(event), + GuardedEvent::TokenRevoked => None, + }) + }); + + Ok(stream) + } + + pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + // Somewhat arbitrarily, expire after 7 days. + let expire_at = relative_to.to_owned() - TimeDelta::days(7); + + let mut tx = self.db.begin().await?; + let tokens = tx.tokens().expire(&expire_at).await?; + tx.commit().await?; + + for event in tokens.into_iter().map(event::TokenRevoked::from) { + self.tokens.broadcast(event); + } + + Ok(()) + } + + pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> { + let mut tx = self.db.begin().await?; + tx.tokens().revoke(token).await?; + tx.commit().await?; + + self.tokens + .broadcast(event::TokenRevoked::from(token.clone())); + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("invalid login")] + Rejected, + #[error(transparent)] + DatabaseError(#[from] sqlx::Error), + #[error(transparent)] + PasswordHashError(#[from] password_hash::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum ValidateError { + #[error("invalid token")] + InvalidToken, + #[error(transparent)] + DatabaseError(#[from] sqlx::Error), +} + +#[derive(Debug)] +enum GuardedEvent<E> { + TokenRevoked, + Event(E), +} diff --git a/src/token/broadcaster.rs b/src/token/broadcaster.rs new file mode 100644 index 0000000..8e2e006 --- /dev/null +++ b/src/token/broadcaster.rs @@ -0,0 +1,4 @@ +use super::event; +use crate::broadcast; + +pub type Broadcaster = broadcast::Broadcaster<event::TokenRevoked>; diff --git a/src/token/event.rs b/src/token/event.rs new file mode 100644 index 0000000..d53d436 --- /dev/null +++ b/src/token/event.rs @@ -0,0 +1,12 @@ +use crate::token; + +#[derive(Clone, Debug)] +pub struct TokenRevoked { + pub token: token::Id, +} + +impl From<token::Id> for TokenRevoked { + fn from(token: token::Id) -> Self { + Self { token } + } +} diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs new file mode 100644 index 0000000..60ad220 --- /dev/null +++ b/src/token/extract/identity.rs @@ -0,0 +1,75 @@ +use axum::{ + extract::{FromRequestParts, State}, + http::request::Parts, + response::{IntoResponse, Response}, +}; + +use super::IdentityToken; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, Unauthorized}, + login::Login, + token::{self, app::ValidateError}, +}; + +#[derive(Clone, Debug)] +pub struct Identity { + pub token: token::Id, + pub login: Login, +} + +#[async_trait::async_trait] +impl FromRequestParts<App> for Identity { + type Rejection = LoginError<Internal>; + + async fn from_request_parts(parts: &mut Parts, state: &App) -> Result<Self, Self::Rejection> { + // After Rust 1.82 (and #[feature(min_exhaustive_patterns)] lands on + // stable), the following can be replaced: + // + // ``` + // let Ok(identity_token) = IdentityToken::from_request_parts( + // parts, + // state, + // ).await; + // ``` + let identity_token = IdentityToken::from_request_parts(parts, state).await?; + let RequestedAt(used_at) = RequestedAt::from_request_parts(parts, state).await?; + + let secret = identity_token.secret().ok_or(LoginError::Unauthorized)?; + + let app = State::<App>::from_request_parts(parts, state).await?; + match app.tokens().validate(&secret, &used_at).await { + Ok((token, login)) => Ok(Identity { token, login }), + Err(ValidateError::InvalidToken) => Err(LoginError::Unauthorized), + Err(other) => Err(other.into()), + } + } +} + +pub enum LoginError<E> { + Failure(E), + Unauthorized, +} + +impl<E> IntoResponse for LoginError<E> +where + E: IntoResponse, +{ + fn into_response(self) -> Response { + match self { + Self::Unauthorized => Unauthorized.into_response(), + Self::Failure(e) => e.into_response(), + } + } +} + +impl<E> From<E> for LoginError<Internal> +where + E: Into<Internal>, +{ + fn from(err: E) -> Self { + Self::Failure(err.into()) + } +} diff --git a/src/token/extract/identity_token.rs b/src/token/extract/identity_token.rs new file mode 100644 index 0000000..0a47a43 --- /dev/null +++ b/src/token/extract/identity_token.rs @@ -0,0 +1,94 @@ +use std::fmt; + +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponseParts, ResponseParts}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; + +use crate::token::Secret; + +// The usage pattern here - receive the extractor as an argument, return it in +// the response - is heavily modelled after CookieJar's own intended usage. +#[derive(Clone)] +pub struct IdentityToken { + cookies: CookieJar, +} + +impl fmt::Debug for IdentityToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IdentityToken") + .field("identity", &self.secret()) + .finish() + } +} + +impl IdentityToken { + // Creates a new, unpopulated identity token store. + #[cfg(test)] + pub fn new() -> Self { + Self { + cookies: CookieJar::new(), + } + } + + // Get the identity secret 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 secret, regardless of what the request originally + // included. + pub fn secret(&self) -> Option<Secret> { + self.cookies + .get(IDENTITY_COOKIE) + .map(Cookie::value) + .map(Secret::from) + } + + // Positively set the identity secret, and ensure that it will be sent + // back to the client when this extractor is included in a response. + pub fn set(self, secret: impl Into<Secret>) -> Self { + let secret = secret.into().reveal(); + let identity_cookie = Cookie::build((IDENTITY_COOKIE, secret)) + .http_only(true) + .path("/api/") + .permanent() + .build(); + + Self { + cookies: self.cookies.add(identity_cookie), + } + } + + // Remove the identity secret and ensure that it will be cleared when this + // extractor is included in a response. + pub fn clear(self) -> Self { + Self { + 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 = <CookieJar as FromRequestParts<S>>::Rejection; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + let cookies = CookieJar::from_request_parts(parts, state).await?; + Ok(Self { cookies }) + } +} + +impl IntoResponseParts for IdentityToken { + type Error = <CookieJar as IntoResponseParts>::Error; + + fn into_response_parts(self, res: ResponseParts) -> Result<ResponseParts, Self::Error> { + let Self { cookies } = self; + cookies.into_response_parts(res) + } +} diff --git a/src/token/extract/mod.rs b/src/token/extract/mod.rs new file mode 100644 index 0000000..b4800ae --- /dev/null +++ b/src/token/extract/mod.rs @@ -0,0 +1,4 @@ +mod identity; +mod identity_token; + +pub use self::{identity::Identity, identity_token::IdentityToken}; diff --git a/src/token/id.rs b/src/token/id.rs new file mode 100644 index 0000000..9ef063c --- /dev/null +++ b/src/token/id.rs @@ -0,0 +1,27 @@ +use std::fmt; + +use crate::id::Id as BaseId; + +// Stable identifier for a token. Prefixed with `T`. +#[derive(Clone, Debug, Eq, Hash, PartialEq, sqlx::Type, serde::Deserialize, serde::Serialize)] +#[sqlx(transparent)] +#[serde(transparent)] +pub struct Id(BaseId); + +impl From<BaseId> for Id { + fn from(id: BaseId) -> Self { + Self(id) + } +} + +impl Id { + pub fn generate() -> Self { + BaseId::generate("T") + } +} + +impl fmt::Display for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} diff --git a/src/token/mod.rs b/src/token/mod.rs new file mode 100644 index 0000000..d122611 --- /dev/null +++ b/src/token/mod.rs @@ -0,0 +1,9 @@ +pub mod app; +pub mod broadcaster; +mod event; +pub mod extract; +mod id; +mod repo; +mod secret; + +pub use self::{id::Id, secret::Secret}; diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs new file mode 100644 index 0000000..b299697 --- /dev/null +++ b/src/token/repo/auth.rs @@ -0,0 +1,50 @@ +use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; + +use crate::login::{self, password::StoredHash, Login}; + +pub trait Provider { + fn auth(&mut self) -> Auth; +} + +impl<'c> Provider for Transaction<'c, Sqlite> { + fn auth(&mut self) -> Auth { + Auth(self) + } +} + +pub struct Auth<'t>(&'t mut SqliteConnection); + +impl<'t> Auth<'t> { + // Retrieves a login by name, plus its stored password hash for + // verification. If there's no login with the requested name, this will + // return [None]. + pub async fn for_name( + &mut self, + name: &str, + ) -> Result<Option<(Login, StoredHash)>, sqlx::Error> { + let found = sqlx::query!( + r#" + select + id as "id: login::Id", + name, + password_hash as "password_hash: StoredHash" + from login + where name = $1 + "#, + name, + ) + .map(|rec| { + ( + Login { + id: rec.id, + name: rec.name, + }, + rec.password_hash, + ) + }) + .fetch_optional(&mut *self.0) + .await?; + + Ok(found) + } +} diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs new file mode 100644 index 0000000..9169743 --- /dev/null +++ b/src/token/repo/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +mod token; + +pub use self::token::Provider; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs new file mode 100644 index 0000000..5f64dac --- /dev/null +++ b/src/token/repo/token.rs @@ -0,0 +1,151 @@ +use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; +use uuid::Uuid; + +use crate::{ + clock::DateTime, + login::{self, Login}, + token::{Id, Secret}, +}; + +pub trait Provider { + fn tokens(&mut self) -> Tokens; +} + +impl<'c> Provider for Transaction<'c, Sqlite> { + fn tokens(&mut self) -> Tokens { + Tokens(self) + } +} + +pub struct Tokens<'t>(&'t mut SqliteConnection); + +impl<'c> Tokens<'c> { + // Issue a new token for an existing login. The issued_at timestamp will + // be used to control expiry, until the token is actually used. + pub async fn issue( + &mut self, + login: &Login, + issued_at: &DateTime, + ) -> Result<Secret, sqlx::Error> { + let id = Id::generate(); + let secret = Uuid::new_v4().to_string(); + + let secret = sqlx::query_scalar!( + r#" + insert + into token (id, secret, login, issued_at, last_used_at) + values ($1, $2, $3, $4, $4) + returning secret as "secret!: Secret" + "#, + id, + secret, + login.id, + issued_at, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(secret) + } + + pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> { + sqlx::query_scalar!( + r#" + select id as "id: Id" + from token + where id = $1 + "#, + token, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(()) + } + + // Revoke a token by its secret. + pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> { + sqlx::query_scalar!( + r#" + delete + from token + where id = $1 + returning id as "id: Id" + "#, + token, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(()) + } + + // Expire and delete all tokens that haven't been used more recently than + // `expire_at`. + pub async fn expire(&mut self, expire_at: &DateTime) -> Result<Vec<Id>, sqlx::Error> { + let tokens = sqlx::query_scalar!( + r#" + delete + from token + where last_used_at < $1 + returning id as "id: Id" + "#, + expire_at, + ) + .fetch_all(&mut *self.0) + .await?; + + Ok(tokens) + } + + // Validate a token by its secret, retrieving the associated Login record. + // Will return [None] if the token is not valid. The token's last-used + // timestamp will be set to `used_at`. + pub async fn validate( + &mut self, + secret: &Secret, + used_at: &DateTime, + ) -> Result<(Id, Login), sqlx::Error> { + // I would use `update … returning` to do this in one query, but + // sqlite3, as of this writing, does not allow an update's `returning` + // clause to reference columns from tables joined into the update. Two + // queries is fine, but it feels untidy. + sqlx::query!( + r#" + update token + set last_used_at = $1 + where secret = $2 + "#, + used_at, + secret, + ) + .execute(&mut *self.0) + .await?; + + let login = sqlx::query!( + r#" + select + token.id as "token_id: Id", + login.id as "login_id: login::Id", + name as "login_name" + from login + join token on login.id = token.login + where token.secret = $1 + "#, + secret, + ) + .map(|row| { + ( + row.token_id, + Login { + id: row.login_id, + name: row.login_name, + }, + ) + }) + .fetch_one(&mut *self.0) + .await?; + + Ok(login) + } +} diff --git a/src/token/secret.rs b/src/token/secret.rs new file mode 100644 index 0000000..28c93bb --- /dev/null +++ b/src/token/secret.rs @@ -0,0 +1,27 @@ +use std::fmt; + +#[derive(sqlx::Type)] +#[sqlx(transparent)] +pub struct Secret(String); + +impl Secret { + pub fn reveal(self) -> String { + let Self(secret) = self; + secret + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("IdentityToken").field(&"********").finish() + } +} + +impl<S> From<S> for Secret +where + S: Into<String>, +{ + fn from(value: S) -> Self { + Self(value.into()) + } +} |
