summaryrefslogtreecommitdiff
path: root/src/token
diff options
context:
space:
mode:
authorKit La Touche <kit@transneptune.net>2024-10-03 23:30:42 -0400
committerKit La Touche <kit@transneptune.net>2024-10-03 23:30:42 -0400
commitd50b1b56c011c03c7d8a95242af404b727e91a80 (patch)
treeefe3408f6a8ef669981826d1a29d16a24b460d89 /src/token
parent30c13478d61065a512f5bc8824fecbf2ee6afc81 (diff)
parent7f12fd41c2941a55a6437f24e4f780104a718790 (diff)
Merge branch 'main' into feature-frontend
Diffstat (limited to 'src/token')
-rw-r--r--src/token/app.rs170
-rw-r--r--src/token/broadcaster.rs4
-rw-r--r--src/token/event.rs12
-rw-r--r--src/token/extract/identity.rs75
-rw-r--r--src/token/extract/identity_token.rs94
-rw-r--r--src/token/extract/mod.rs4
-rw-r--r--src/token/id.rs27
-rw-r--r--src/token/mod.rs9
-rw-r--r--src/token/repo/auth.rs50
-rw-r--r--src/token/repo/mod.rs4
-rw-r--r--src/token/repo/token.rs151
-rw-r--r--src/token/secret.rs27
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())
+ }
+}