summaryrefslogtreecommitdiff
path: root/src/token
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-10-02 01:02:58 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-10-02 01:02:58 -0400
commit5d3392799f88c5a3d3f9c656c73d6e8ac5c4d793 (patch)
tree426c568d82b67a98095d25952d2b5b2345a6545b /src/token
parent357116366c1307bedaac6a3dfe9c5ed8e0e0c210 (diff)
Split login and token handling.
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.rs6
-rw-r--r--src/token/mod.rs4
-rw-r--r--src/token/repo/auth.rs50
-rw-r--r--src/token/repo/mod.rs4
-rw-r--r--src/token/repo/token.rs151
8 files changed, 398 insertions, 3 deletions
diff --git a/src/token/app.rs b/src/token/app.rs
new file mode 100644
index 0000000..1477a9f
--- /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,
+ login::{repo::Provider as _, Login, Password},
+ repo::error::NotFound as _,
+};
+
+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
index 42c7c60..60ad220 100644
--- a/src/token/extract/identity.rs
+++ b/src/token/extract/identity.rs
@@ -10,8 +10,8 @@ use crate::{
app::App,
clock::RequestedAt,
error::{Internal, Unauthorized},
- login::{app::ValidateError, Login},
- token,
+ login::Login,
+ token::{self, app::ValidateError},
};
#[derive(Clone, Debug)]
@@ -40,7 +40,7 @@ impl FromRequestParts<App> for Identity {
let secret = identity_token.secret().ok_or(LoginError::Unauthorized)?;
let app = State::<App>::from_request_parts(parts, state).await?;
- match app.logins().validate(&secret, &used_at).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()),
diff --git a/src/token/mod.rs b/src/token/mod.rs
index c98b8c2..d122611 100644
--- a/src/token/mod.rs
+++ b/src/token/mod.rs
@@ -1,5 +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)
+ }
+}