diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2025-08-24 17:03:16 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2025-08-26 01:08:11 -0400 |
| commit | 0bbc83f09cc7517dddf16770a15f9e90815f48ba (patch) | |
| tree | 4b7ea51aab2e9255fb8832d3109b4bc8dc033f0c | |
| parent | 218d6dbb56727721d19019c8514f5e4395596e98 (diff) | |
Generate tokens in memory and then store them.
This is the leading edge of a larger storage refactoring, where repo types stop doing things like generating secrets or deciding whether to carry out an operation. To make this work, there is now a `Token` type that holds the complete state of a token, in memory.
| -rw-r--r-- | .sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json | 12 | ||||
| -rw-r--r-- | .sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json | 38 | ||||
| -rw-r--r-- | .sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json | 12 | ||||
| -rw-r--r-- | .sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json | 26 | ||||
| -rw-r--r-- | .sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json | 20 | ||||
| -rw-r--r-- | .sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json | 20 | ||||
| -rw-r--r-- | src/event/handlers/stream/mod.rs | 2 | ||||
| -rw-r--r-- | src/invite/app.rs | 6 | ||||
| -rw-r--r-- | src/setup/app.rs | 21 | ||||
| -rw-r--r-- | src/test/fixtures/identity.rs | 4 | ||||
| -rw-r--r-- | src/token/app.rs | 25 | ||||
| -rw-r--r-- | src/token/extract/identity.rs | 4 | ||||
| -rw-r--r-- | src/token/mod.rs | 32 | ||||
| -rw-r--r-- | src/token/repo/token.rs | 61 | ||||
| -rw-r--r-- | src/user/history.rs | 1 |
15 files changed, 158 insertions, 126 deletions
diff --git a/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json new file mode 100644 index 0000000..37a6dd3 --- /dev/null +++ b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8" +} diff --git a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json new file mode 100644 index 0000000..99a05e3 --- /dev/null +++ b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: user::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login: user::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issued_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_used_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72" +} diff --git a/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json new file mode 100644 index 0000000..c9edfc9 --- /dev/null +++ b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from token\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4" +} diff --git a/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json b/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json deleted file mode 100644 index 1efb9a7..0000000 --- a/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n login as \"login: user::Id\"\n ", - "describe": { - "columns": [ - { - "name": "token: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "login: user::Id", - "ordinal": 1, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false - ] - }, - "hash": "7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234" -} diff --git a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json b/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json deleted file mode 100644 index b433e4c..0000000 --- a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $4)\n returning secret as \"secret!: Secret\"\n ", - "describe": { - "columns": [ - { - "name": "secret!: Secret", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false - ] - }, - "hash": "8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769" -} diff --git a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json b/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json deleted file mode 100644 index 1be8e07..0000000 --- a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n delete\n from token\n where id = $1\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f" -} diff --git a/src/event/handlers/stream/mod.rs b/src/event/handlers/stream/mod.rs index d0d3f08..63bfff3 100644 --- a/src/event/handlers/stream/mod.rs +++ b/src/event/handlers/stream/mod.rs @@ -27,7 +27,7 @@ pub async fn handler( let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner); let stream = app.events().subscribe(resume_at).await?; - let stream = app.tokens().limit_stream(identity.token, stream).await?; + let stream = app.tokens().limit_stream(&identity.token, stream).await?; Ok(Response(stream)) } diff --git a/src/invite/app.rs b/src/invite/app.rs index 1c85562..6e235b2 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -8,7 +8,7 @@ use crate::{ event::Broadcaster, name::Name, password::Password, - token::{Secret, repo::Provider as _}, + token::{Secret, Token, repo::Provider as _}, user::{ User, create::{self, Create}, @@ -71,7 +71,9 @@ impl<'a> Invites<'a> { .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(stored.user(), accepted_at).await?; + let user = stored.user().as_created(); + let (token, secret) = Token::generate(&user, accepted_at); + tx.tokens().create(&token, &secret).await?; tx.commit().await?; stored.publish(self.events); diff --git a/src/setup/app.rs b/src/setup/app.rs index 1856519..c1c53c5 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -6,7 +6,7 @@ use crate::{ event::Broadcaster, name::Name, password::Password, - token::{Secret, repo::Provider as _}, + token::{Secret, Token, repo::Provider as _}, user::create::{self, Create}, }; @@ -31,17 +31,20 @@ impl<'a> Setup<'a> { let validated = create.validate()?; let mut tx = self.db.begin().await?; - let stored = if tx.setup().completed().await? { - Err(Error::SetupCompleted)? + if tx.setup().completed().await? { + Err(Error::SetupCompleted) } else { - validated.store(&mut tx).await? - }; - let secret = tx.tokens().issue(stored.user(), created_at).await?; - tx.commit().await?; + let stored = validated.store(&mut tx).await?; + let user = stored.user().as_created(); + + let (token, secret) = Token::generate(&user, created_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; - stored.publish(self.events); + stored.publish(self.events); - Ok(secret) + Ok(secret) + } } pub async fn completed(&self) -> Result<bool, sqlx::Error> { diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index 29ff5ae..ac353de 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -5,7 +5,7 @@ use crate::{ password::Password, test::fixtures, token::{ - self, + Token, extract::{Identity, IdentityCookie}, }, }; @@ -37,8 +37,8 @@ pub async fn logged_in( } pub fn fictitious() -> Identity { - let token = token::Id::generate(); let user = fixtures::user::fictitious(); + let (token, _) = Token::generate(&user, &fixtures::now()); Identity { token, user } } diff --git a/src/token/app.rs b/src/token/app.rs index 8ec61c5..a7a843d 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -6,7 +6,7 @@ use futures::{ use sqlx::sqlite::SqlitePool; use super::{ - Broadcaster, Event as TokenEvent, Id, Secret, + Broadcaster, Event as TokenEvent, Secret, Token, extract::Identity, repo::{self, Provider as _, auth::Provider as _}, }; @@ -48,12 +48,14 @@ impl<'a> Tokens<'a> { // if the account is deleted during that time. tx.commit().await?; - user.as_snapshot().ok_or(LoginError::Rejected)?; + let user = user.as_snapshot().ok_or(LoginError::Rejected)?; if stored_hash.verify(password)? { let mut tx = self.db.begin().await?; - let secret = tx.tokens().issue(&user, login_at).await?; + let (token, secret) = Token::generate(&user, login_at); + tx.tokens().create(&token, &secret).await?; tx.commit().await?; + Ok(secret) } else { Err(LoginError::Rejected) @@ -85,13 +87,16 @@ impl<'a> Tokens<'a> { return Err(LoginError::Rejected); } - user.as_snapshot().ok_or(LoginError::Rejected)?; + let user_snapshot = user.as_snapshot().ok_or(LoginError::Rejected)?; let to_hash = to.hash()?; let mut tx = self.db.begin().await?; - let tokens = tx.tokens().revoke_all(&user).await?; tx.users().set_password(&user, &to_hash).await?; - let secret = tx.tokens().issue(&user, changed_at).await?; + + let tokens = tx.tokens().revoke_all(&user).await?; + let (token, secret) = Token::generate(&user_snapshot, changed_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; for event in tokens.into_iter().map(TokenEvent::Revoked) { @@ -121,13 +126,15 @@ impl<'a> Tokens<'a> { pub async fn limit_stream<S, E>( &self, - token: Id, + token: &Token, events: S, ) -> Result<impl Stream<Item = E> + std::fmt::Debug + use<S, E>, ValidateError> where S: Stream<Item = E> + std::fmt::Debug, E: std::fmt::Debug, { + let token = token.id.clone(); + // Subscribe, first. let token_events = self.token_events.subscribe(); @@ -188,13 +195,13 @@ impl<'a> Tokens<'a> { Ok(()) } - pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> { + pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> { let mut tx = self.db.begin().await?; tx.tokens().revoke(token).await?; tx.commit().await?; self.token_events - .broadcast(TokenEvent::Revoked(token.clone())); + .broadcast(TokenEvent::Revoked(token.id.clone())); Ok(()) } diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index 4d076d7..d01ab53 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -10,13 +10,13 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, Unauthorized}, - token::{self, app::ValidateError}, + token::{Token, app::ValidateError}, user::User, }; #[derive(Clone, Debug)] pub struct Identity { - pub token: token::Id, + pub token: Token, pub user: User, } diff --git a/src/token/mod.rs b/src/token/mod.rs index 33403ef..58ff08b 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -6,4 +6,36 @@ mod id; pub mod repo; mod secret; +use uuid::Uuid; + +use crate::{ + clock::DateTime, + user::{self, User}, +}; + pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret}; + +#[derive(Clone, Debug)] +pub struct Token { + pub id: Id, + pub user: user::Id, + pub issued_at: DateTime, + pub last_used_at: DateTime, +} + +impl Token { + pub fn generate(user: &User, issued_at: &DateTime) -> (Self, Secret) { + let id = Id::generate(); + let secret = Uuid::new_v4().to_string().into(); + + ( + Self { + id, + user: user.id.clone(), + issued_at: *issued_at, + last_used_at: *issued_at, + }, + secret, + ) + } +} diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index 5368fee..afcde53 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -1,12 +1,11 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; -use uuid::Uuid; use crate::{ clock::DateTime, db::NotFound, event::{Instant, Sequence}, name::{self, Name}, - token::{Id, Secret}, + token::{Id, Secret, Token}, user::{self, History, User}, }; @@ -23,33 +22,23 @@ impl Provider for Transaction<'_, Sqlite> { pub struct Tokens<'t>(&'t mut SqliteConnection); impl Tokens<'_> { - // Issue a new token for an existing user. The issued_at timestamp will - // determine the token's initial expiry deadline. - pub async fn issue( - &mut self, - user: &History, - issued_at: &DateTime, - ) -> Result<Secret, sqlx::Error> { - let id = Id::generate(); - let secret = Uuid::new_v4().to_string(); - let user = user.id(); - - let secret = sqlx::query_scalar!( + pub async fn create(&mut self, token: &Token, secret: &Secret) -> Result<(), sqlx::Error> { + sqlx::query!( r#" insert into token (id, secret, login, issued_at, last_used_at) - values ($1, $2, $3, $4, $4) - returning secret as "secret!: Secret" + values ($1, $2, $3, $4, $5) "#, - id, + token.id, secret, - user, - issued_at, + token.user, + token.issued_at, + token.last_used_at, ) .fetch_one(&mut *self.0) .await?; - Ok(secret) + Ok(()) } pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> { @@ -67,18 +56,15 @@ impl Tokens<'_> { Ok(()) } - // Revoke a token by its secret. - pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> { - sqlx::query_scalar!( + pub async fn revoke(&mut self, token: &Token) -> Result<(), sqlx::Error> { + sqlx::query!( r#" - delete - from token + delete from token where id = $1 - returning id as "id: Id" "#, - token, + token.id, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; Ok(()) @@ -127,24 +113,31 @@ impl Tokens<'_> { &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Id, History), LoadError> { + ) -> Result<(Token, History), LoadError> { // 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. - let (token, user) = sqlx::query!( + let token = sqlx::query!( r#" update token set last_used_at = $1 where secret = $2 returning - id as "token: Id", - login as "login: user::Id" + id as "id: Id", + login as "login: user::Id", + issued_at as "issued_at: DateTime", + last_used_at as "last_used_at: DateTime" "#, used_at, secret, ) - .map(|row| (row.token, row.login)) + .map(|row| Token { + id: row.id, + user: row.login, + issued_at: row.issued_at, + last_used_at: row.last_used_at, + }) .fetch_one(&mut *self.0) .await?; @@ -160,7 +153,7 @@ impl Tokens<'_> { join login using (id) where id = $1 "#, - user, + token.user, ) .map(|row| { Ok::<_, name::Error>(History { diff --git a/src/user/history.rs b/src/user/history.rs index 4f99130..72e0aee 100644 --- a/src/user/history.rs +++ b/src/user/history.rs @@ -20,7 +20,6 @@ impl History { // if this returns a redacted or modified version of the user. If we implement // renames by redacting the original name, then this should return the edited // user, not the original, even if that's not how it was "as created.") - #[cfg(test)] pub fn as_created(&self) -> User { self.user.clone() } |
