diff options
64 files changed, 820 insertions, 645 deletions
diff --git a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json index 99a05e3..ca93083 100644 --- a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json +++ b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json @@ -1,6 +1,6 @@ { "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 ", + "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: login::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ", "describe": { "columns": [ { @@ -9,7 +9,7 @@ "type_info": "Text" }, { - "name": "login: user::Id", + "name": "login: login::Id", "ordinal": 1, "type_info": "Text" }, @@ -34,5 +34,5 @@ false ] }, - "hash": "2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72" + "hash": "047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271" } diff --git a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json b/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json deleted file mode 100644 index 4bc5119..0000000 --- a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: user::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "password: StoredHash", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c" -} diff --git a/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json b/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json deleted file mode 100644 index f4ae749..0000000 --- a/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where login.canonical_name = $1\n ", - "describe": { - "columns": [ - { - "name": "id: user::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "password: StoredHash", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b" -} diff --git a/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json b/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json new file mode 100644 index 0000000..b5c9c81 --- /dev/null +++ b/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name,\n canonical_name,\n password as \"password: StoredHash\"\n from login\n where canonical_name = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password: StoredHash", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079" +} diff --git a/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json b/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json new file mode 100644 index 0000000..05d04e3 --- /dev/null +++ b/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n update login\n set password = $1\n where id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95" +} diff --git a/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json b/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json new file mode 100644 index 0000000..56232dd --- /dev/null +++ b/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: login::Id\",\n display_name,\n canonical_name\n from login\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a" +} diff --git a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json index 1a4389f..789151d 100644 --- a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json +++ b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json @@ -1,10 +1,10 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where id = $1\n ", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_at as \"created_at: DateTime\",\n user.created_sequence as \"created_sequence: Sequence\"\n from user\n join login using (id)\n where id = $1\n ", "describe": { "columns": [ { - "name": "id: user::Id", + "name": "id: Id", "ordinal": 0, "type_info": "Text" }, @@ -19,14 +19,14 @@ "type_info": "Text" }, { - "name": "created_sequence: Sequence", + "name": "created_at: DateTime", "ordinal": 3, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "created_at: DateTime", + "name": "created_sequence: Sequence", "ordinal": 4, - "type_info": "Text" + "type_info": "Integer" } ], "parameters": { @@ -40,5 +40,5 @@ false ] }, - "hash": "48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509" + "hash": "c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e" } diff --git a/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json b/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json new file mode 100644 index 0000000..00b0ab9 --- /dev/null +++ b/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name,\n canonical_name,\n password as \"password: StoredHash\"\n from login\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password: StoredHash", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389" +} diff --git a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json b/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json deleted file mode 100644 index 200c357..0000000 --- a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update login\n set password = $1\n where id = $2\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false - ] - }, - "hash": "cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c" -} diff --git a/docs/api/boot.md b/docs/api/boot.md index d7e9144..cc59d79 100644 --- a/docs/api/boot.md +++ b/docs/api/boot.md @@ -37,7 +37,7 @@ This endpoint will respond with a status of ```json { - "user": { + "login": { "name": "example username", "id": "U1234abcd" }, @@ -100,15 +100,14 @@ The response will include the following fields: | Field | Type | Description | | :------------- | :-------------- | :----------------------------------------------------------------------------------------------------------------------- | -| `user` | object | The details of the caller's identity. | +| `login` | object | The details of the caller's identity. | | `resume_point` | integer | A resume point for [events](./events.md), such that the event stream will begin immediately after the included snapshot. | | `heartbeat` | integer | The [heartbeat timeout](./events.md#heartbeat-events), in seconds, for events. | | `events` | array of object | The events on the server up to the resume point. | -Each element of the -`events` object is an event, as described in [Events](./events.md). Events are provided in the same order as they would appear in the event stream response. +Each element of the `events` object is an event, as described in [Events](./events.md). Events are provided in the same order as they would appear in the event stream response. -The `user` object will include the following fields: +The `login` object will include the following fields: | Field | Type | Description | | :----- | :----- | :--------------------------------------- | @@ -5,6 +5,7 @@ use crate::{ conversation::app::Conversations, event::{self, app::Events}, invite::app::Invites, + login::app::Logins, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, @@ -49,9 +50,8 @@ impl App { Invites::new(&self.db, &self.events) } - #[cfg(test)] - pub const fn users(&self) -> Users<'_> { - Users::new(&self.db, &self.events) + pub const fn logins(&self) -> Logins<'_> { + Logins::new(&self.db, &self.token_events) } pub const fn messages(&self) -> Messages<'_> { @@ -65,4 +65,9 @@ impl App { pub const fn tokens(&self) -> Tokens<'_> { Tokens::new(&self.db, &self.token_events) } + + #[cfg(test)] + pub const fn users(&self) -> Users<'_> { + Users::new(&self.db, &self.events) + } } diff --git a/src/boot/handlers/boot/mod.rs b/src/boot/handlers/boot/mod.rs index 49691f7..3e022b1 100644 --- a/src/boot/handlers/boot/mod.rs +++ b/src/boot/handlers/boot/mod.rs @@ -7,8 +7,8 @@ use axum::{ use serde::Serialize; use crate::{ - app::App, boot::Snapshot, error::Internal, event::Heartbeat, token::extract::Identity, - user::User, + app::App, boot::Snapshot, error::Internal, event::Heartbeat, login::Login, + token::extract::Identity, }; #[cfg(test)] @@ -19,7 +19,7 @@ pub async fn handler(State(app): State<App>, identity: Identity) -> Result<Respo let heartbeat = Heartbeat::TIMEOUT; Ok(Response { - user: identity.user, + login: identity.login, heartbeat, snapshot, }) @@ -27,7 +27,7 @@ pub async fn handler(State(app): State<App>, identity: Identity) -> Result<Respo #[derive(serde::Serialize)] pub struct Response { - pub user: User, + pub login: Login, #[serde(serialize_with = "as_seconds")] pub heartbeat: Duration, #[serde(flatten)] diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs index c7c511a..cb50442 100644 --- a/src/boot/handlers/boot/test.rs +++ b/src/boot/handlers/boot/test.rs @@ -12,7 +12,7 @@ async fn returns_identity() { .await .expect("boot always succeeds"); - assert_eq!(viewer.user, response.user); + assert_eq!(viewer.login, response.login); } #[tokio::test] @@ -33,7 +33,7 @@ async fn includes_users() { .filter_map(fixtures::event::user::created) .exactly_one() .expect("only one user has been created"); - assert_eq!(spectator, created.user) + assert_eq!(spectator.id, created.user.id); } #[tokio::test] diff --git a/src/conversation/handlers/send/mod.rs b/src/conversation/handlers/send/mod.rs index 1c8ac63..c8be59c 100644 --- a/src/conversation/handlers/send/mod.rs +++ b/src/conversation/handlers/send/mod.rs @@ -25,7 +25,7 @@ pub async fn handler( ) -> Result<Response, Error> { let message = app .messages() - .send(&conversation, &identity.user, &sent_at, &request.body) + .send(&conversation, &identity.login, &sent_at, &request.body) .await?; Ok(Response(message)) @@ -57,7 +57,10 @@ impl IntoResponse for Error { SendError::ConversationNotFound(_) | SendError::ConversationDeleted(_) => { NotFound(error).into_response() } - SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(), + SendError::SenderNotFound(_) + | SendError::SenderDeleted(_) + | SendError::Name(_) + | SendError::Database(_) => Internal::from(error).into_response(), } } } diff --git a/src/conversation/handlers/send/test.rs b/src/conversation/handlers/send/test.rs index bd32510..8863090 100644 --- a/src/conversation/handlers/send/test.rs +++ b/src/conversation/handlers/send/test.rs @@ -55,7 +55,7 @@ async fn messages_in_order() { .await { assert_eq!(*sent_at, event.at()); - assert_eq!(sender.user.id, event.message.sender); + assert_eq!(sender.login.id, event.message.sender); assert_eq!(body, event.message.body); } } diff --git a/src/event/handlers/stream/test/invite.rs b/src/event/handlers/stream/test/invite.rs index ce22384..d4ac82d 100644 --- a/src/event/handlers/stream/test/invite.rs +++ b/src/event/handlers/stream/test/invite.rs @@ -44,7 +44,7 @@ async fn accepting_invite() { let _ = events .filter_map(fixtures::event::stream::user) .filter_map(fixtures::event::stream::user::created) - .filter(|event| future::ready(event.user == joiner.user)) + .filter(|event| future::ready(event.user.id == joiner.login.id)) .next() .expect_some("a login created event is sent") .await; @@ -90,7 +90,7 @@ async fn previously_accepted_invite() { let _ = events .filter_map(fixtures::event::stream::user) .filter_map(fixtures::event::stream::user::created) - .filter(|event| future::ready(event.user == joiner.user)) + .filter(|event| future::ready(event.user.id == joiner.login.id)) .next() .expect_some("a login created event is sent") .await; diff --git a/src/event/handlers/stream/test/setup.rs b/src/event/handlers/stream/test/setup.rs index 1fd2b7c..3f01ccb 100644 --- a/src/event/handlers/stream/test/setup.rs +++ b/src/event/handlers/stream/test/setup.rs @@ -45,7 +45,7 @@ async fn previously_completed() { let _ = events .filter_map(fixtures::event::stream::user) .filter_map(fixtures::event::stream::user::created) - .filter(|event| future::ready(event.user == owner.user)) + .filter(|event| future::ready(event.user.id == owner.login.id)) .next() .expect_some("a login created event is sent") .await; diff --git a/src/event/handlers/stream/test/token.rs b/src/event/handlers/stream/test/token.rs index 5af07a0..a9dfe29 100644 --- a/src/event/handlers/stream/test/token.rs +++ b/src/event/handlers/stream/test/token.rs @@ -125,8 +125,8 @@ async fn terminates_on_password_change() { let (_, password) = creds; let to = fixtures::user::propose_password(); - app.tokens() - .change_password(&subscriber.user, &password, &to, &fixtures::now()) + app.logins() + .change_password(&subscriber.login, &password, &to, &fixtures::now()) .await .expect("expiring tokens succeeds"); diff --git a/src/invite/app.rs b/src/invite/app.rs index 6e235b2..6684d03 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -5,13 +5,15 @@ use super::{Id, Invite, Summary, repo::Provider as _}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, - event::Broadcaster, - name::Name, + event::{Broadcaster, repo::Provider as _}, + login::Login, + name::{self, Name}, password::Password, token::{Secret, Token, repo::Provider as _}, user::{ - User, + self, create::{self, Create}, + repo::{LoadError, Provider as _}, }, }; @@ -25,9 +27,19 @@ impl<'a> Invites<'a> { Self { db, events } } - pub async fn issue(&self, issuer: &User, issued_at: &DateTime) -> Result<Invite, sqlx::Error> { + pub async fn issue(&self, issuer: &Login, issued_at: &DateTime) -> Result<Invite, Error> { + let issuer_not_found = || Error::IssuerNotFound(issuer.id.clone().into()); + let issuer_deleted = || Error::IssuerDeleted(issuer.id.clone().into()); + let mut tx = self.db.begin().await?; - let invite = tx.invites().create(issuer, issued_at).await?; + let issuer = tx + .users() + .by_login(issuer) + .await + .not_found(issuer_not_found)?; + let now = tx.sequence().current().await?; + let issuer = issuer.as_of(now).ok_or_else(issuer_deleted)?; + let invite = tx.invites().create(&issuer, issued_at).await?; tx.commit().await?; Ok(invite) @@ -71,8 +83,8 @@ impl<'a> Invites<'a> { .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let user = stored.user().as_created(); - let (token, secret) = Token::generate(&user, accepted_at); + let login = stored.login(); + let (token, secret) = Token::generate(login, accepted_at); tx.tokens().create(&token, &secret).await?; tx.commit().await?; @@ -94,6 +106,28 @@ impl<'a> Invites<'a> { } #[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("issuing user {0} not found")] + IssuerNotFound(user::Id), + #[error("issuing user {0} deleted")] + IssuerDeleted(user::Id), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<user::repo::LoadError> for Error { + fn from(error: LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + +#[derive(Debug, thiserror::Error)] pub enum AcceptError { #[error("invite not found: {0}")] NotFound(Id), diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs index 4e4a09d..283ec76 100644 --- a/src/invite/handlers/accept/test.rs +++ b/src/invite/handlers/accept/test.rs @@ -44,8 +44,8 @@ async fn valid_invite() { // Verify that the given credentials can log in let secret = app - .tokens() - .login(&name, &password, &fixtures::now()) + .logins() + .with_password(&name, &password, &fixtures::now()) .await .expect("credentials given on signup are valid"); verify::token::valid_for_name(&app, &secret, &name).await; diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs index 6085f7a..4ac74cc 100644 --- a/src/invite/handlers/issue/mod.rs +++ b/src/invite/handlers/issue/mod.rs @@ -13,7 +13,7 @@ pub async fn handler( identity: Identity, _: Json<Request>, ) -> Result<Json<Invite>, Internal> { - let invite = app.invites().issue(&identity.user, &issued_at).await?; + let invite = app.invites().issue(&identity.login, &issued_at).await?; Ok(Json(invite)) } diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs index 2bf5400..4421705 100644 --- a/src/invite/handlers/issue/test.rs +++ b/src/invite/handlers/issue/test.rs @@ -22,6 +22,6 @@ async fn create_invite() { .expect("creating an invite always succeeds"); // Verify the response - assert_eq!(issuer.user.id, invite.issuer); + assert_eq!(issuer.login.id, invite.issuer); assert_eq!(&*issued_at, &invite.issued_at); } @@ -15,6 +15,7 @@ mod event; mod expire; mod id; mod invite; +mod login; mod message; mod name; mod normalize; diff --git a/src/login/app.rs b/src/login/app.rs new file mode 100644 index 0000000..77d4ac3 --- /dev/null +++ b/src/login/app.rs @@ -0,0 +1,114 @@ +use sqlx::sqlite::SqlitePool; + +use crate::{ + clock::DateTime, + db::NotFound as _, + login::{self, Login, repo::Provider as _}, + name::{self, Name}, + password::Password, + token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _}, +}; + +pub struct Logins<'a> { + db: &'a SqlitePool, + token_events: &'a Broadcaster, +} + +impl<'a> Logins<'a> { + pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self { + Self { db, token_events } + } + + pub async fn with_password( + &self, + name: &Name, + candidate: &Password, + login_at: &DateTime, + ) -> Result<Secret, LoginError> { + let mut tx = self.db.begin().await?; + let (login, password) = tx + .logins() + .by_name(name) + .await + .not_found(|| LoginError::Rejected)?; + // Split the transaction here to avoid holding the tx open (potentially blocking + // other writes) while we do the fairly expensive task of verifying the + // password. It's okay if the token issuance transaction happens some notional + // amount of time after retrieving the login, as inserting the token will fail + // if the account is deleted during that time. + tx.commit().await?; + + if password.verify(candidate)? { + let mut tx = self.db.begin().await?; + let (token, secret) = Token::generate(&login, login_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; + Ok(secret) + } else { + Err(LoginError::Rejected) + } + } + + pub async fn change_password( + &self, + login: &Login, + from: &Password, + to: &Password, + changed_at: &DateTime, + ) -> Result<Secret, LoginError> { + let mut tx = self.db.begin().await?; + let (login, password) = tx + .logins() + .by_id(&login.id) + .await + .not_found(|| LoginError::Rejected)?; + // Split the transaction here to avoid holding the tx open (potentially blocking + // other writes) while we do the fairly expensive task of verifying the + // password. It's okay if the token issuance transaction happens some notional + // amount of time after retrieving the login, as inserting the token will fail + // if the account is deleted during that time. + tx.commit().await?; + + if password.verify(from)? { + let to_hash = to.hash()?; + let (token, secret) = Token::generate(&login, changed_at); + + let mut tx = self.db.begin().await?; + tx.logins().set_password(&login, &to_hash).await?; + + let revoked = tx.tokens().revoke_all(&login).await?; + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; + + for event in revoked.into_iter().map(TokenEvent::Revoked) { + self.token_events.broadcast(event); + } + + Ok(secret) + } else { + Err(LoginError::Rejected) + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("invalid login")] + Rejected, + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} + +impl From<login::repo::LoadError> for LoginError { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} diff --git a/src/user/handlers/login/mod.rs b/src/login/handlers/login/mod.rs index d3e0e8c..6591984 100644 --- a/src/user/handlers/login/mod.rs +++ b/src/login/handlers/login/mod.rs @@ -5,13 +5,8 @@ use axum::{ }; use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::Internal, - name::Name, - password::Password, - token::{app, extract::IdentityCookie}, + app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name, + password::Password, token::extract::IdentityCookie, }; #[cfg(test)] @@ -24,8 +19,8 @@ pub async fn handler( Json(request): Json<Request>, ) -> Result<(IdentityCookie, Empty), Error> { let secret = app - .tokens() - .login(&request.name, &request.password, &now) + .logins() + .with_password(&request.name, &request.password, &now) .await .map_err(Error)?; let identity = identity.set(secret); diff --git a/src/user/handlers/login/test.rs b/src/login/handlers/login/test.rs index 56fc2c4..f3911d0 100644 --- a/src/user/handlers/login/test.rs +++ b/src/login/handlers/login/test.rs @@ -2,8 +2,8 @@ use axum::extract::{Json, State}; use crate::{ empty::Empty, + login::app::LoginError, test::{fixtures, verify}, - token::app, }; #[tokio::test] @@ -28,7 +28,12 @@ async fn correct_credentials() { // Verify the return value's basic structure - verify::identity::valid_for_name(&app, &identity, &name).await; + let secret = identity + .secret() + .expect("logged in with valid credentials issues an identity cookie"); + + // Verify the semantics + verify::token::valid_for_name(&app, &secret, &name).await; } #[tokio::test] @@ -53,7 +58,7 @@ async fn invalid_name() { // Verify the return value's basic structure - assert!(matches!(error, app::LoginError::Rejected)); + assert!(matches!(error, LoginError::Rejected)); } #[tokio::test] @@ -78,7 +83,7 @@ async fn incorrect_password() { // Verify the return value's basic structure - assert!(matches!(error, app::LoginError::Rejected)); + assert!(matches!(error, LoginError::Rejected)); } #[tokio::test] @@ -100,9 +105,8 @@ async fn token_expires() { // Verify the semantics - let expired_at = fixtures::now(); app.tokens() - .expire(&expired_at) + .expire(&fixtures::now()) .await .expect("expiring tokens never fails"); diff --git a/src/user/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs index f759451..73efe73 100644 --- a/src/user/handlers/logout/mod.rs +++ b/src/login/handlers/logout/mod.rs @@ -21,8 +21,8 @@ pub async fn handler( Json(_): Json<Request>, ) -> Result<(IdentityCookie, Empty), Error> { if let Some(secret) = identity.secret() { - let validated_ident = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&validated_ident.token).await?; + let identity = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&identity.token).await?; } let identity = identity.clear(); @@ -42,9 +42,7 @@ impl IntoResponse for Error { fn into_response(self) -> Response { let Self(error) = self; match error { - app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => { - Unauthorized.into_response() - } + app::ValidateError::InvalidToken => Unauthorized.into_response(), app::ValidateError::Name(_) | app::ValidateError::Database(_) => { Internal::from(error).into_response() } diff --git a/src/user/handlers/logout/test.rs b/src/login/handlers/logout/test.rs index 8ad4853..e7b7dd4 100644 --- a/src/user/handlers/logout/test.rs +++ b/src/login/handlers/logout/test.rs @@ -14,7 +14,6 @@ async fn successful() { let now = fixtures::now(); let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; - let secret = fixtures::cookie::secret(&identity); // Call the endpoint @@ -31,7 +30,7 @@ async fn successful() { assert!(response_identity.secret().is_none()); // Verify the semantics - verify::token::invalid(&app, &secret).await; + verify::identity::invalid(&app, &identity).await; } #[tokio::test] diff --git a/src/user/handlers/mod.rs b/src/login/handlers/mod.rs index 5cadbb5..24ee7f9 100644 --- a/src/user/handlers/mod.rs +++ b/src/login/handlers/mod.rs @@ -1,6 +1,6 @@ mod login; mod logout; -mod password; +pub mod password; pub use login::handler as login; pub use logout::handler as logout; diff --git a/src/user/handlers/password/mod.rs b/src/login/handlers/password/mod.rs index 5e69c1c..94c7fb4 100644 --- a/src/user/handlers/password/mod.rs +++ b/src/login/handlers/password/mod.rs @@ -9,11 +9,9 @@ use crate::{ clock::RequestedAt, empty::Empty, error::Internal, + login::app, password::Password, - token::{ - app, - extract::{Identity, IdentityCookie}, - }, + token::extract::{Identity, IdentityCookie}, }; #[cfg(test)] @@ -27,8 +25,8 @@ pub async fn handler( Json(request): Json<Request>, ) -> Result<(IdentityCookie, Empty), Error> { let secret = app - .tokens() - .change_password(&identity.user, &request.password, &request.to, &now) + .logins() + .change_password(&identity.login, &request.password, &request.to, &now) .await .map_err(Error)?; let cookie = cookie.set(secret); diff --git a/src/user/handlers/password/test.rs b/src/login/handlers/password/test.rs index 81020a1..ba2f28f 100644 --- a/src/user/handlers/password/test.rs +++ b/src/login/handlers/password/test.rs @@ -34,7 +34,7 @@ async fn password_change() { assert_ne!(cookie.secret(), new_cookie.secret()); // Verify that we're still ourselves - verify::identity::valid_for_user(&app, &new_cookie, &identity.user).await; + verify::identity::valid_for_login(&app, &new_cookie, &identity.login).await; // Verify that our original token is no longer valid verify::identity::invalid(&app, &cookie).await; diff --git a/src/login/id.rs b/src/login/id.rs new file mode 100644 index 0000000..ab16a15 --- /dev/null +++ b/src/login/id.rs @@ -0,0 +1,27 @@ +use crate::user; + +pub type Id = crate::id::Id<Login>; + +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From<user::Id> for Id { + fn from(user: user::Id) -> Self { + Self::from(String::from(user)) + } +} + +impl PartialEq<user::Id> for Id { + fn eq(&self, other: &user::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Login; + +impl crate::id::Prefix for Login { + fn prefix(&self) -> &'static str { + user::id::User.prefix() + } +} diff --git a/src/login/mod.rs b/src/login/mod.rs new file mode 100644 index 0000000..bccc2af --- /dev/null +++ b/src/login/mod.rs @@ -0,0 +1,13 @@ +pub mod app; +pub mod handlers; +mod id; +pub mod repo; + +use crate::name::Name; +pub use id::Id; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct Login { + pub id: Id, + pub name: Name, +} diff --git a/src/login/repo.rs b/src/login/repo.rs new file mode 100644 index 0000000..5be91ad --- /dev/null +++ b/src/login/repo.rs @@ -0,0 +1,145 @@ +use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; + +use super::{Id, Login}; +use crate::{ + db::NotFound, + name::{self, Name}, + password::StoredHash, +}; + +pub trait Provider { + fn logins(&mut self) -> Logins<'_>; +} + +impl Provider for Transaction<'_, Sqlite> { + fn logins(&mut self) -> Logins<'_> { + Logins(self) + } +} + +pub struct Logins<'t>(&'t mut SqliteConnection); + +impl Logins<'_> { + pub async fn create( + &mut self, + login: &Login, + password: &StoredHash, + ) -> Result<(), sqlx::Error> { + let Login { id, name } = login; + let display_name = name.display(); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + insert into login (id, display_name, canonical_name, password) + values ($1, $2, $3, $4) + "#, + id, + display_name, + canonical_name, + password, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } + + pub async fn by_id(&mut self, id: &Id) -> Result<(Login, StoredHash), LoadError> { + let user = sqlx::query!( + r#" + select + id as "id: Id", + display_name, + canonical_name, + password as "password: StoredHash" + from login + where id = $1 + "#, + id, + ) + .map(|row| { + Ok::<_, LoadError>(( + Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + row.password, + )) + }) + .fetch_one(&mut *self.0) + .await??; + + Ok(user) + } + + pub async fn by_name(&mut self, name: &Name) -> Result<(Login, StoredHash), LoadError> { + let canonical_name = name.canonical(); + + let (login, password) = sqlx::query!( + r#" + select + id as "id: Id", + display_name, + canonical_name, + password as "password: StoredHash" + from login + where canonical_name = $1 + "#, + canonical_name, + ) + .map(|row| { + Ok::<_, LoadError>(( + Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + row.password, + )) + }) + .fetch_one(&mut *self.0) + .await??; + + Ok((login, password)) + } + + pub async fn set_password( + &mut self, + login: &Login, + password: &StoredHash, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + update login + set password = $1 + where id = $2 + "#, + password, + login.id, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/src/message/app.rs b/src/message/app.rs index bdc2164..9100224 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -8,8 +8,9 @@ use crate::{ conversation::{self, repo::Provider as _}, db::NotFound as _, event::{Broadcaster, Event, Sequence, repo::Provider as _}, + login::Login, name, - user::User, + user::{self, repo::Provider as _}, }; pub struct Messages<'a> { @@ -25,27 +26,35 @@ impl<'a> Messages<'a> { pub async fn send( &self, conversation: &conversation::Id, - sender: &User, + sender: &Login, sent_at: &DateTime, body: &Body, ) -> Result<Message, SendError> { - let to_not_found = || SendError::ConversationNotFound(conversation.clone()); - let to_deleted = || SendError::ConversationDeleted(conversation.clone()); + let conversation_not_found = || SendError::ConversationNotFound(conversation.clone()); + let conversation_deleted = || SendError::ConversationDeleted(conversation.clone()); + let sender_not_found = || SendError::SenderNotFound(sender.id.clone().into()); + let sender_deleted = || SendError::SenderDeleted(sender.id.clone().into()); let mut tx = self.db.begin().await?; let conversation = tx .conversations() .by_id(conversation) .await - .not_found(to_not_found)?; + .not_found(conversation_not_found)?; + let sender = tx + .users() + .by_login(sender) + .await + .not_found(sender_not_found)?; // Ordering: don't bother allocating a sequence number before we know the channel might // exist. let sent = tx.sequence().next(sent_at).await?; - let conversation = conversation.as_of(sent).ok_or_else(to_deleted)?; + let conversation = conversation.as_of(sent).ok_or_else(conversation_deleted)?; + let sender = sender.as_of(sent).ok_or_else(sender_deleted)?; let message = tx .messages() - .create(&conversation, sender, &sent, body) + .create(&conversation, &sender, &sent, body) .await?; tx.commit().await?; @@ -57,36 +66,48 @@ impl<'a> Messages<'a> { pub async fn delete( &self, - deleted_by: &User, + deleted_by: &Login, message: &Id, deleted_at: &DateTime, ) -> Result<(), DeleteError> { + let message_not_found = || DeleteError::MessageNotFound(message.clone()); + let message_deleted = || DeleteError::Deleted(message.clone()); + let deleter_not_found = || DeleteError::UserNotFound(deleted_by.id.clone().into()); + let deleter_deleted = || DeleteError::UserDeleted(deleted_by.id.clone().into()); + let not_sender = || DeleteError::NotSender(deleted_by.id.clone().into()); + let mut tx = self.db.begin().await?; let message = tx .messages() .by_id(message) .await - .not_found(|| DeleteError::NotFound(message.clone()))?; - let snapshot = message - .as_snapshot() - .ok_or_else(|| DeleteError::Deleted(message.id().clone()))?; - if snapshot.sender != deleted_by.id { - return Err(DeleteError::NotSender(deleted_by.clone())); - } + .not_found(message_not_found)?; + let deleted_by = tx + .users() + .by_login(deleted_by) + .await + .not_found(deleter_not_found)?; let deleted = tx.sequence().next(deleted_at).await?; - let message = tx.messages().delete(&message, &deleted).await?; - tx.commit().await?; + let message = message.as_of(deleted).ok_or_else(message_deleted)?; + let deleted_by = deleted_by.as_of(deleted).ok_or_else(deleter_deleted)?; - self.events.broadcast( - message - .events() - .filter(Sequence::start_from(deleted.sequence)) - .map(Event::from) - .collect::<Vec<_>>(), - ); + if message.sender == deleted_by.id { + let message = tx.messages().delete(&message, &deleted).await?; + tx.commit().await?; - Ok(()) + self.events.broadcast( + message + .events() + .filter(Sequence::start_from(deleted.sequence)) + .map(Event::from) + .collect::<Vec<_>>(), + ); + + Ok(()) + } else { + Err(not_sender()) + } } pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { @@ -99,12 +120,14 @@ impl<'a> Messages<'a> { let mut events = Vec::with_capacity(expired.len()); for message in expired { let deleted = tx.sequence().next(relative_to).await?; - let message = tx.messages().delete(&message, &deleted).await?; - events.push( - message - .events() - .filter(Sequence::start_from(deleted.sequence)), - ); + if let Some(message) = message.as_of(deleted) { + let message = tx.messages().delete(&message, &deleted).await?; + events.push( + message + .events() + .filter(Sequence::start_from(deleted.sequence)), + ); + } } tx.commit().await?; @@ -138,6 +161,10 @@ pub enum SendError { ConversationNotFound(conversation::Id), #[error("conversation {0} deleted")] ConversationDeleted(conversation::Id), + #[error("user {0} not found")] + SenderNotFound(user::Id), + #[error("user {0} deleted")] + SenderDeleted(user::Id), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] @@ -154,14 +181,40 @@ impl From<conversation::repo::LoadError> for SendError { } } +impl From<user::repo::LoadError> for SendError { + fn from(error: user::repo::LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum DeleteError { #[error("message {0} not found")] - NotFound(Id), - #[error("user {} not the message's sender", .0.id)] - NotSender(User), + MessageNotFound(Id), + #[error("user {0} not found")] + UserNotFound(user::Id), + #[error("user {0} deleted")] + UserDeleted(user::Id), + #[error("user {0} not the message's sender")] + NotSender(user::Id), #[error("message {0} deleted")] Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<user::repo::LoadError> for DeleteError { + fn from(error: user::repo::LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs index 5eac4eb..606f502 100644 --- a/src/message/handlers/delete/mod.rs +++ b/src/message/handlers/delete/mod.rs @@ -22,7 +22,7 @@ pub async fn handler( identity: Identity, ) -> Result<Response, Error> { app.messages() - .delete(&identity.user, &message, &deleted_at) + .delete(&identity.login, &message, &deleted_at) .await?; Ok(Response { id: message }) @@ -48,8 +48,13 @@ impl IntoResponse for Error { let Self(error) = self; match error { DeleteError::NotSender(_) => (StatusCode::FORBIDDEN, error.to_string()).into_response(), - DeleteError::NotFound(_) | DeleteError::Deleted(_) => NotFound(error).into_response(), - DeleteError::Database(_) => Internal::from(error).into_response(), + DeleteError::MessageNotFound(_) | DeleteError::Deleted(_) => { + NotFound(error).into_response() + } + DeleteError::UserNotFound(_) + | DeleteError::UserDeleted(_) + | DeleteError::Database(_) + | DeleteError::Name(_) => Internal::from(error).into_response(), } } } diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs index 371c7bf..d0e1794 100644 --- a/src/message/handlers/delete/test.rs +++ b/src/message/handlers/delete/test.rs @@ -11,7 +11,7 @@ pub async fn delete_message() { let sender = fixtures::identity::create(&app, &fixtures::now()).await; let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let message = - fixtures::message::send(&app, &conversation, &sender.user, &fixtures::now()).await; + fixtures::message::send(&app, &conversation, &sender.login, &fixtures::now()).await; // Send the request @@ -62,7 +62,7 @@ pub async fn delete_invalid_message_id() { // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message)); + assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message)); } #[tokio::test] @@ -160,7 +160,7 @@ pub async fn delete_purged() { // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id)); + assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message.id)); } #[tokio::test] @@ -187,6 +187,6 @@ pub async fn delete_not_sender() { // Verify the response assert!( - matches!(error, app::DeleteError::NotSender(error_sender) if deleter.user == error_sender) + matches!(error, app::DeleteError::NotSender(error_sender) if deleter.login.id == error_sender) ); } diff --git a/src/message/history.rs b/src/message/history.rs index d4d4500..2abdf2c 100644 --- a/src/message/history.rs +++ b/src/message/history.rs @@ -1,7 +1,7 @@ use itertools::Itertools as _; use super::{ - Id, Message, + Message, event::{Deleted, Event, Sent}, }; use crate::event::Sequence; @@ -13,10 +13,6 @@ pub struct History { // State interface impl History { - pub fn id(&self) -> &Id { - &self.message.id - } - // Snapshot of this message as it was when sent. (Note to the future: it's okay // if this returns a redacted or modified version of the message. If we // implement message editing by redacting the original body, then this should @@ -26,6 +22,15 @@ impl History { self.message.clone() } + pub fn as_of<S>(&self, sequence: S) -> Option<Message> + where + S: Into<Sequence>, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() + } + // Snapshot of this message as of all events recorded in this history. pub fn as_snapshot(&self) -> Option<Message> { self.events().collect() diff --git a/src/message/repo.rs b/src/message/repo.rs index 2e9700a..83bf0d5 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -180,17 +180,15 @@ impl Messages<'_> { pub async fn delete( &mut self, - message: &History, + message: &Message, deleted: &Instant, ) -> Result<History, sqlx::Error> { - let id = message.id(); - sqlx::query!( r#" insert into message_deleted (id, deleted_at, deleted_sequence) values ($1, $2, $3) "#, - id, + message.id, deleted.at, deleted.sequence, ) @@ -209,12 +207,12 @@ impl Messages<'_> { returning id as "id: Id" "#, deleted.sequence, - id, + message.id, ) .fetch_one(&mut *self.0) .await?; - let message = self.by_id(id).await?; + let message = self.by_id(&message.id).await?; Ok(message) } diff --git a/src/routes.rs b/src/routes.rs index 6993070..5b9e15a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,7 +4,7 @@ use axum::{ routing::{delete, get, post}, }; -use crate::{app::App, boot, conversation, event, expire, invite, message, setup, ui, user}; +use crate::{app::App, boot, conversation, event, expire, invite, login, message, setup, ui}; pub fn routes(app: &App) -> Router<App> { // UI routes that can be accessed before the administrator completes setup. @@ -27,8 +27,8 @@ pub fn routes(app: &App) -> Router<App> { // API routes that require the administrator to complete setup first. let api_setup_required = Router::new() - .route("/api/auth/login", post(user::handlers::login)) - .route("/api/auth/logout", post(user::handlers::logout)) + .route("/api/auth/login", post(login::handlers::login)) + .route("/api/auth/logout", post(login::handlers::logout)) .route("/api/boot", get(boot::handlers::boot)) .route("/api/conversations", post(conversation::handlers::create)) .route( @@ -44,7 +44,7 @@ pub fn routes(app: &App) -> Router<App> { .route("/api/invite/{invite}", get(invite::handlers::get)) .route("/api/invite/{invite}", post(invite::handlers::accept)) .route("/api/messages/{message}", delete(message::handlers::delete)) - .route("/api/password", post(user::handlers::change_password)) + .route("/api/password", post(login::handlers::change_password)) // Run expiry whenever someone accesses the API. This was previously a blanket middleware // affecting the whole service, but loading the client makes a several requests before the // client can completely load, each of which was triggering expiry. There is absolutely no diff --git a/src/setup/app.rs b/src/setup/app.rs index c1c53c5..2a8ec30 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -35,9 +35,9 @@ impl<'a> Setup<'a> { Err(Error::SetupCompleted) } else { let stored = validated.store(&mut tx).await?; - let user = stored.user().as_created(); + let login = stored.login(); - let (token, secret) = Token::generate(&user, created_at); + let (token, secret) = Token::generate(login, created_at); tx.tokens().create(&token, &secret).await?; tx.commit().await?; diff --git a/src/test/fixtures/cookie.rs b/src/test/fixtures/cookie.rs index f5a32a6..7dc5083 100644 --- a/src/test/fixtures/cookie.rs +++ b/src/test/fixtures/cookie.rs @@ -1,11 +1,7 @@ use uuid::Uuid; use crate::{ - app::App, - clock::RequestedAt, - name::Name, - password::Password, - token::{Secret, extract::IdentityCookie}, + app::App, clock::RequestedAt, name::Name, password::Password, token::extract::IdentityCookie, }; pub fn not_logged_in() -> IdentityCookie { @@ -19,18 +15,14 @@ pub async fn logged_in( ) -> IdentityCookie { let (name, password) = credentials; let secret = app - .tokens() - .login(name, password, now) + .logins() + .with_password(name, password, now) .await .expect("should succeed given known-valid credentials"); IdentityCookie::new().set(secret) } -pub fn secret(identity: &IdentityCookie) -> Secret { - identity.secret().expect("identity contained a secret") -} - pub fn fictitious() -> IdentityCookie { let token = Uuid::new_v4().to_string(); IdentityCookie::new().set(token) diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index ac353de..93e4a38 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -37,8 +37,8 @@ pub async fn logged_in( } pub fn fictitious() -> Identity { - let user = fixtures::user::fictitious(); - let (token, _) = Token::generate(&user, &fixtures::now()); + let login = fixtures::login::fictitious(); + let (token, _) = Token::generate(&login, &fixtures::now()); - Identity { token, user } + Identity { token, login } } diff --git a/src/test/fixtures/invite.rs b/src/test/fixtures/invite.rs index 7a41eb6..654d1b4 100644 --- a/src/test/fixtures/invite.rs +++ b/src/test/fixtures/invite.rs @@ -2,10 +2,10 @@ use crate::{ app::App, clock::DateTime, invite::{self, Invite}, - user::User, + login::Login, }; -pub async fn issue(app: &App, issuer: &User, issued_at: &DateTime) -> Invite { +pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite { app.invites() .issue(issuer, issued_at) .await diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs new file mode 100644 index 0000000..d9aca81 --- /dev/null +++ b/src/test/fixtures/login.rs @@ -0,0 +1,21 @@ +use crate::{ + app::App, + clock::DateTime, + login::{self, Login}, + test::fixtures::user::{propose, propose_name}, +}; + +pub async fn create(app: &App, created_at: &DateTime) -> Login { + let (name, password) = propose(); + app.users() + .create(&name, &password, created_at) + .await + .expect("should always succeed if the user is actually new") +} + +pub fn fictitious() -> Login { + Login { + id: login::Id::generate(), + name: propose_name(), + } +} diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 03f8072..92ac1f5 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -4,14 +4,14 @@ use crate::{ app::App, clock::RequestedAt, conversation::Conversation, + login::Login, message::{self, Body, Message}, - user::User, }; pub async fn send( app: &App, conversation: &Conversation, - sender: &User, + sender: &Login, sent_at: &RequestedAt, ) -> Message { let body = propose(); diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 87d3fa1..3d69cfa 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -9,6 +9,7 @@ pub mod event; pub mod future; pub mod identity; pub mod invite; +pub mod login; pub mod message; pub mod user; diff --git a/src/test/fixtures/user.rs b/src/test/fixtures/user.rs index 086f866..d4d8db4 100644 --- a/src/test/fixtures/user.rs +++ b/src/test/fixtures/user.rs @@ -1,13 +1,7 @@ use faker_rand::{en_us::internet, lorem::Paragraphs}; use uuid::Uuid; -use crate::{ - app::App, - clock::RequestedAt, - name::Name, - password::Password, - user::{self, User}, -}; +use crate::{app::App, clock::RequestedAt, login::Login, name::Name, password::Password}; pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) { let (name, password) = propose(); @@ -20,19 +14,8 @@ pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, (user.name, password) } -pub async fn create(app: &App, created_at: &RequestedAt) -> User { - let (name, password) = propose(); - app.users() - .create(&name, &password, created_at) - .await - .expect("should always succeed if the login is actually new") -} - -pub fn fictitious() -> User { - User { - id: user::Id::generate(), - name: propose_name(), - } +pub async fn create(app: &App, created_at: &RequestedAt) -> Login { + super::login::create(app, created_at).await } pub fn propose() -> (Name, Password) { @@ -43,7 +26,7 @@ pub fn propose_invalid_name() -> Name { rand::random::<Paragraphs>().to_string().into() } -fn propose_name() -> Name { +pub(crate) fn propose_name() -> Name { rand::random::<internet::Username>().to_string().into() } diff --git a/src/test/verify/identity.rs b/src/test/verify/identity.rs index 226ee74..8e2d36e 100644 --- a/src/test/verify/identity.rs +++ b/src/test/verify/identity.rs @@ -1,9 +1,9 @@ use crate::{ app::App, + login::Login, name::Name, test::{fixtures, verify}, token::{app::ValidateError, extract::IdentityCookie}, - user::User, }; pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) { @@ -13,11 +13,11 @@ pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) { verify::token::valid_for_name(app, &secret, name).await; } -pub async fn valid_for_user(app: &App, identity: &IdentityCookie, user: &User) { +pub async fn valid_for_login(app: &App, identity: &IdentityCookie, login: &Login) { let secret = identity .secret() .expect("identity cookie must be set to be valid"); - verify::token::valid_for_user(app, &secret, user).await; + verify::token::valid_for_login(app, &secret, login).await; } pub async fn invalid(app: &App, identity: &IdentityCookie) { diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs index 3f291a3..ae2e91e 100644 --- a/src/test/verify/login.rs +++ b/src/test/verify/login.rs @@ -1,15 +1,15 @@ use crate::{ app::App, + login::app::LoginError, name::Name, password::Password, test::{fixtures, verify}, - token::app::LoginError, }; pub async fn valid_login(app: &App, name: &Name, password: &Password) { let secret = app - .tokens() - .login(&name, &password, &fixtures::now()) + .logins() + .with_password(name, password, &fixtures::now()) .await .expect("login credentials expected to be valid"); verify::token::valid_for_name(&app, &secret, &name).await; @@ -17,8 +17,8 @@ pub async fn valid_login(app: &App, name: &Name, password: &Password) { pub async fn invalid_login(app: &App, name: &Name, password: &Password) { let error = app - .tokens() - .login(name, password, &fixtures::now()) + .logins() + .with_password(name, password, &fixtures::now()) .await .expect_err("login credentials expected not to be valid"); assert!(matches!(error, LoginError::Rejected)); diff --git a/src/test/verify/token.rs b/src/test/verify/token.rs index 99cd31f..adc4397 100644 --- a/src/test/verify/token.rs +++ b/src/test/verify/token.rs @@ -1,9 +1,9 @@ use crate::{ app::App, + login::Login, name::Name, test::fixtures, token::{Secret, app}, - user::User, }; pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) { @@ -12,16 +12,16 @@ pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) { .validate(secret, &fixtures::now()) .await .expect("provided secret is valid"); - assert_eq!(name, &identity.user.name); + assert_eq!(name, &identity.login.name); } -pub async fn valid_for_user(app: &App, secret: &Secret, user: &User) { +pub async fn valid_for_login(app: &App, secret: &Secret, login: &Login) { let identity = app .tokens() .validate(secret, &fixtures::now()) .await .expect("provided secret is valid"); - assert_eq!(user, &identity.user); + assert_eq!(login, &identity.login); } pub async fn invalid(app: &App, secret: &Secret) { diff --git a/src/token/app.rs b/src/token/app.rs index a7a843d..fb5d712 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -8,15 +8,9 @@ use sqlx::sqlite::SqlitePool; use super::{ Broadcaster, Event as TokenEvent, Secret, Token, extract::Identity, - repo::{self, Provider as _, auth::Provider as _}, -}; -use crate::{ - clock::DateTime, - db::NotFound as _, - name::{self, Name}, - password::Password, - user::{User, repo::Provider as _}, + repo::{self, Provider as _}, }; +use crate::{clock::DateTime, db::NotFound as _, name}; pub struct Tokens<'a> { db: &'a SqlitePool, @@ -28,100 +22,20 @@ impl<'a> Tokens<'a> { Self { db, token_events } } - pub async fn login( - &self, - name: &Name, - password: &Password, - login_at: &DateTime, - ) -> Result<Secret, LoginError> { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_name(name) - .await - .optional()? - .ok_or(LoginError::Rejected)?; - // Split the transaction here to avoid holding the tx open (potentially blocking - // other writes) while we do the fairly expensive task of verifying the - // password. It's okay if the token issuance transaction happens some notional - // amount of time after retrieving the login, as inserting the token will fail - // if the account is deleted during that time. - tx.commit().await?; - - let user = user.as_snapshot().ok_or(LoginError::Rejected)?; - - if stored_hash.verify(password)? { - let mut tx = self.db.begin().await?; - let (token, secret) = Token::generate(&user, login_at); - tx.tokens().create(&token, &secret).await?; - tx.commit().await?; - - Ok(secret) - } else { - Err(LoginError::Rejected) - } - } - - pub async fn change_password( - &self, - user: &User, - password: &Password, - to: &Password, - changed_at: &DateTime, - ) -> Result<Secret, LoginError> { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_user(user) - .await - .optional()? - .ok_or(LoginError::Rejected)?; - // Split the transaction here to avoid holding the tx open (potentially blocking - // other writes) while we do the fairly expensive task of verifying the - // password. It's okay if the token issuance transaction happens some notional - // amount of time after retrieving the login, as inserting the token will fail - // if the account is deleted during that time. - tx.commit().await?; - - if !stored_hash.verify(password)? { - return Err(LoginError::Rejected); - } - - let user_snapshot = user.as_snapshot().ok_or(LoginError::Rejected)?; - let to_hash = to.hash()?; - - let mut tx = self.db.begin().await?; - tx.users().set_password(&user, &to_hash).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) { - self.token_events.broadcast(event); - } - - Ok(secret) - } - pub async fn validate( &self, secret: &Secret, used_at: &DateTime, ) -> Result<Identity, ValidateError> { let mut tx = self.db.begin().await?; - let (token, user) = tx + let (token, login) = tx .tokens() .validate(secret, used_at) .await .not_found(|| ValidateError::InvalidToken)?; tx.commit().await?; - let user = user.as_snapshot().ok_or(ValidateError::LoginDeleted)?; - - Ok(Identity { token, user }) + Ok(Identity { token, login }) } pub async fn limit_stream<S, E>( @@ -208,33 +122,9 @@ impl<'a> Tokens<'a> { } #[derive(Debug, thiserror::Error)] -pub enum LoginError { - #[error("invalid login")] - Rejected, - #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] - Name(#[from] name::Error), - #[error(transparent)] - PasswordHash(#[from] password_hash::Error), -} - -impl From<repo::auth::LoadError> for LoginError { - fn from(error: repo::auth::LoadError) -> Self { - use repo::auth::LoadError; - match error { - LoadError::Database(error) => error.into(), - LoadError::Name(error) => error.into(), - } - } -} - -#[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] InvalidToken, - #[error("user deleted")] - LoginDeleted, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index d01ab53..960fe60 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -10,14 +10,14 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, Unauthorized}, + login::Login, token::{Token, app::ValidateError}, - user::User, }; #[derive(Clone, Debug)] pub struct Identity { pub token: Token, - pub user: User, + pub login: Login, } impl FromRequestParts<App> for Identity { diff --git a/src/token/mod.rs b/src/token/mod.rs index 58ff08b..b2dd6f1 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -8,30 +8,26 @@ mod secret; use uuid::Uuid; -use crate::{ - clock::DateTime, - user::{self, User}, -}; - pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret}; +use crate::{clock::DateTime, login, login::Login}; #[derive(Clone, Debug)] pub struct Token { pub id: Id, - pub user: user::Id, + pub login: login::Id, pub issued_at: DateTime, pub last_used_at: DateTime, } impl Token { - pub fn generate(user: &User, issued_at: &DateTime) -> (Self, Secret) { + pub fn generate(login: &Login, issued_at: &DateTime) -> (Self, Secret) { let id = Id::generate(); let secret = Uuid::new_v4().to_string().into(); ( Self { id, - user: user.id.clone(), + login: login.id.clone(), issued_at: *issued_at, last_used_at: *issued_at, }, diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs deleted file mode 100644 index a42fa1a..0000000 --- a/src/token/repo/auth.rs +++ /dev/null @@ -1,105 +0,0 @@ -use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; - -use crate::{ - clock::DateTime, - db::NotFound, - event::{Instant, Sequence}, - name::{self, Name}, - password::StoredHash, - user::{self, History, User}, -}; - -pub trait Provider { - fn auth(&mut self) -> Auth<'_>; -} - -impl Provider for Transaction<'_, Sqlite> { - fn auth(&mut self) -> Auth<'_> { - Auth(self) - } -} - -pub struct Auth<'t>(&'t mut SqliteConnection); - -impl Auth<'_> { - pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), LoadError> { - let name = name.canonical(); - let row = sqlx::query!( - r#" - select - id as "id: user::Id", - login.display_name as "display_name: String", - login.canonical_name as "canonical_name: String", - user.created_sequence as "created_sequence: Sequence", - user.created_at as "created_at: DateTime", - login.password as "password: StoredHash" - from user - join login using (id) - where login.canonical_name = $1 - "#, - name, - ) - .fetch_one(&mut *self.0) - .await?; - - let login = History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), - }; - - Ok((login, row.password)) - } - - pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> { - let row = sqlx::query!( - r#" - select - id as "id: user::Id", - login.display_name as "display_name: String", - login.canonical_name as "canonical_name: String", - user.created_sequence as "created_sequence: Sequence", - user.created_at as "created_at: DateTime", - login.password as "password: StoredHash" - from user - join login using (id) - where id = $1 - "#, - user.id, - ) - .fetch_one(&mut *self.0) - .await?; - - let user = History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), - }; - - Ok((user, row.password)) - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum LoadError { - Database(#[from] sqlx::Error), - Name(#[from] name::Error), -} - -impl<T> NotFound for Result<T, LoadError> { - type Ok = T; - type Error = LoadError; - - fn optional(self) -> Result<Option<T>, LoadError> { - match self { - Ok(value) => Ok(Some(value)), - Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), - Err(other) => Err(other), - } - } -} diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs index d8463eb..9df5bbb 100644 --- a/src/token/repo/mod.rs +++ b/src/token/repo/mod.rs @@ -1,4 +1,3 @@ -pub mod auth; mod token; pub use self::token::{LoadError, Provider}; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index afcde53..52a3987 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -3,10 +3,9 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use crate::{ clock::DateTime, db::NotFound, - event::{Instant, Sequence}, + login::{self, Login}, name::{self, Name}, token::{Id, Secret, Token}, - user::{self, History, User}, }; pub trait Provider { @@ -31,11 +30,11 @@ impl Tokens<'_> { "#, token.id, secret, - token.user, + token.login, token.issued_at, token.last_used_at, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; Ok(()) @@ -71,8 +70,7 @@ impl Tokens<'_> { } // Revoke tokens for a login - pub async fn revoke_all(&mut self, user: &user::History) -> Result<Vec<Id>, sqlx::Error> { - let user = user.id(); + pub async fn revoke_all(&mut self, login: &Login) -> Result<Vec<Id>, sqlx::Error> { let tokens = sqlx::query_scalar!( r#" delete @@ -80,7 +78,7 @@ impl Tokens<'_> { where login = $1 returning id as "id: Id" "#, - user, + login.id, ) .fetch_all(&mut *self.0) .await?; @@ -106,14 +104,11 @@ impl Tokens<'_> { Ok(tokens) } - // Validate a token by its secret, retrieving the associated Login record. - // Will return an error if the token is not valid. If successful, the - // retrieved token's last-used timestamp will be set to `used_at`. pub async fn validate( &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Token, History), LoadError> { + ) -> Result<(Token, Login), 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 @@ -125,7 +120,7 @@ impl Tokens<'_> { where secret = $2 returning id as "id: Id", - login as "login: user::Id", + login as "login: login::Id", issued_at as "issued_at: DateTime", last_used_at as "last_used_at: DateTime" "#, @@ -134,7 +129,7 @@ impl Tokens<'_> { ) .map(|row| Token { id: row.id, - user: row.login, + login: row.login, issued_at: row.issued_at, last_used_at: row.last_used_at, }) @@ -144,24 +139,18 @@ impl Tokens<'_> { let user = sqlx::query!( r#" select - id as "id: user::Id", - login.display_name as "display_name: String", - login.canonical_name as "canonical_name: String", - user.created_sequence as "created_sequence: Sequence", - user.created_at as "created_at: DateTime" - from user - join login using (id) + id as "id: login::Id", + display_name, + canonical_name + from login where id = $1 "#, - token.user, + token.login, ) .map(|row| { - Ok::<_, name::Error>(History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), + Ok::<_, name::Error>(Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, }) }) .fetch_one(&mut *self.0) diff --git a/src/user/app.rs b/src/user/app.rs index 301c39c..0d6046c 100644 --- a/src/user/app.rs +++ b/src/user/app.rs @@ -1,10 +1,7 @@ use sqlx::sqlite::SqlitePool; -use super::{ - User, - create::{self, Create}, -}; -use crate::{clock::DateTime, event::Broadcaster, name::Name, password::Password}; +use super::create::{self, Create}; +use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password}; pub struct Users<'a> { db: &'a SqlitePool, @@ -21,7 +18,7 @@ impl<'a> Users<'a> { name: &Name, password: &Password, created_at: &DateTime, - ) -> Result<User, CreateError> { + ) -> Result<Login, CreateError> { let create = Create::begin(name, password, created_at); let validated = create.validate()?; @@ -29,10 +26,10 @@ impl<'a> Users<'a> { let stored = validated.store(&mut tx).await?; tx.commit().await?; - let user = stored.user().to_owned(); + let login = stored.login().to_owned(); stored.publish(self.events); - Ok(user.as_created()) + Ok(login) } } @@ -46,7 +43,6 @@ pub enum CreateError { Database(#[from] sqlx::Error), } -#[cfg(test)] impl From<create::Error> for CreateError { fn from(error: create::Error) -> Self { match error { diff --git a/src/user/create.rs b/src/user/create.rs index 5d7bf65..5c060c9 100644 --- a/src/user/create.rs +++ b/src/user/create.rs @@ -4,6 +4,7 @@ use super::{History, repo::Provider as _, validate}; use crate::{ clock::DateTime, event::{Broadcaster, Event, repo::Provider as _}, + login::{self, Login, repo::Provider as _}, name::Name, password::{Password, StoredHash}, }; @@ -39,7 +40,7 @@ impl<'a> Create<'a> { Ok(Validated { name, - password_hash, + password: password_hash, created_at, }) } @@ -48,7 +49,7 @@ impl<'a> Create<'a> { #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Validated<'a> { name: &'a Name, - password_hash: StoredHash, + password: StoredHash, created_at: &'a DateTime, } @@ -56,31 +57,38 @@ impl Validated<'_> { pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result<Stored, sqlx::Error> { let Self { name, - password_hash, + password, created_at, } = self; + let login = Login { + id: login::Id::generate(), + name: name.to_owned(), + }; + let created = tx.sequence().next(created_at).await?; - let user = tx.users().create(name, &password_hash, &created).await?; + tx.logins().create(&login, &password).await?; + let user = tx.users().create(&login, &created).await?; - Ok(Stored { user }) + Ok(Stored { user, login }) } } #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Stored { user: History, + login: Login, } impl Stored { - pub fn publish(self, events: &Broadcaster) { - let Self { user } = self; + pub fn publish(self, broadcaster: &Broadcaster) { + let Self { user, login: _ } = self; - events.broadcast(user.events().map(Event::from).collect::<Vec<_>>()); + broadcaster.broadcast(user.events().map(Event::from).collect::<Vec<_>>()); } - pub fn user(&self) -> &History { - &self.user + pub fn login(&self) -> &Login { + &self.login } } diff --git a/src/user/history.rs b/src/user/history.rs index 72e0aee..f58e9c7 100644 --- a/src/user/history.rs +++ b/src/user/history.rs @@ -1,8 +1,8 @@ use super::{ - Id, User, + User, event::{Created, Event}, }; -use crate::event::Instant; +use crate::event::{Instant, Sequence}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct History { @@ -12,21 +12,13 @@ pub struct History { // State interface impl History { - pub fn id(&self) -> &Id { - &self.user.id - } - - // Snapshot of this user as it was when created. (Note to the future: it's okay - // 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.") - pub fn as_created(&self) -> User { - self.user.clone() - } - - // Snapshot of this user, as of all events recorded in this history. - pub fn as_snapshot(&self) -> Option<User> { - self.events().collect() + pub fn as_of<S>(&self, sequence: S) -> Option<User> + where + S: Into<Sequence>, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() } } diff --git a/src/user/id.rs b/src/user/id.rs index 3ad8d16..ceb310a 100644 --- a/src/user/id.rs +++ b/src/user/id.rs @@ -1,7 +1,24 @@ +use crate::login; + // Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L` // prefix, instead. pub type Id = crate::id::Id<User>; +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From<login::Id> for Id { + fn from(login: login::Id) -> Self { + Self::from(String::from(login)) + } +} + +impl PartialEq<login::Id> for Id { + fn eq(&self, other: &login::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct User; diff --git a/src/user/mod.rs b/src/user/mod.rs index 60ec209..95bec2f 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -2,9 +2,8 @@ pub mod app; pub mod create; pub mod event; -pub mod handlers; mod history; -mod id; +pub mod id; pub mod repo; mod snapshot; mod validate; diff --git a/src/user/repo.rs b/src/user/repo.rs index bfb603d..aaf3b73 100644 --- a/src/user/repo.rs +++ b/src/user/repo.rs @@ -3,9 +3,10 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use crate::{ clock::DateTime, + db::NotFound, event::{Instant, Sequence}, + login::Login, name::{self, Name}, - password::StoredHash, user::{History, Id, User}, }; @@ -24,71 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection); impl Users<'_> { pub async fn create( &mut self, - name: &Name, - password: &StoredHash, + login: &Login, created: &Instant, ) -> Result<History, sqlx::Error> { - let id = Id::generate(); - let display_name = name.display(); - let canonical_name = name.canonical(); - - sqlx::query!( - r#" - insert into login (id, display_name, canonical_name, password) - values ($1, $2, $3, $4) - "#, - id, - display_name, - canonical_name, - password, - ) - .execute(&mut *self.0) - .await?; - sqlx::query!( r#" insert into user (id, created_sequence, created_at) values ($1, $2, $3) "#, - id, + login.id, created.sequence, created.at, ) .execute(&mut *self.0) .await?; - let user = History { - created: *created, + Ok(History { user: User { - id, - name: name.clone(), + id: login.id.clone().into(), + name: login.name.clone(), }, - }; - - Ok(user) + created: *created, + }) } - pub async fn set_password( - &mut self, - login: &History, - to: &StoredHash, - ) -> Result<(), sqlx::Error> { - let login = login.id(); - - sqlx::query_scalar!( + pub async fn by_login(&mut self, login: &Login) -> Result<History, LoadError> { + let user = sqlx::query!( r#" - update login - set password = $1 - where id = $2 - returning id as "id: Id" + select + id as "id: Id", + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_at as "created_at: DateTime", + user.created_sequence as "created_sequence: Sequence" + from user + join login using (id) + where id = $1 "#, - to, - login, + login.id, ) + .map(|row| { + Ok::<_, LoadError>(History { + user: User { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) + }) .fetch_one(&mut *self.0) - .await?; + .await??; - Ok(()) + Ok(user) } pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> { @@ -163,3 +151,16 @@ pub enum LoadError { Database(#[from] sqlx::Error), Name(#[from] name::Error), } + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index 42b86f0..c415d0c 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -62,9 +62,9 @@ class Session { ), ); - static boot({ user, resume_point, heartbeat, events }) { + static boot({ login, resume_point, heartbeat, events }) { const remote = r.State.boot({ - currentUser: user, + currentUser: login, resumePoint: resume_point, heartbeat, events, @@ -73,9 +73,9 @@ class Session { return new Session(remote, local); } - reboot({ user, resume_point, heartbeat, events }) { + reboot({ login, resume_point, heartbeat, events }) { this.remote = r.State.boot({ - currentUser: user, + currentUser: login, resumePoint: resume_point, heartbeat, events, |
