From 0bbc83f09cc7517dddf16770a15f9e90815f48ba Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 24 Aug 2025 17:03:16 -0400 Subject: 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. --- src/token/mod.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) (limited to 'src/token/mod.rs') 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, + ) + } +} -- cgit v1.2.3 From d0d5fa20200a7ad70173ba87ae47c33b60f44a3b Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 26 Aug 2025 00:44:29 -0400 Subject: Split `user` into a chat-facing entity and an authentication-facing entity. The taxonomy is now as follows: * A _login_ is someone's identity for the purposes of authenticating to the service. Logins are not synchronized, and in fact are not published anywhere in the current API. They have a login ID, a name and a password. * A _user_ is someone's identity for the purpose of participating in conversations. Users _are_ synchronized, as before. They have a user ID, a name, and a creation instant for the purposes of synchronization. In practice, a user exists for every login - in fact, users' names are stored in the login table and are joined in, rather than being stored redundantly in the user table. A login ID and its corresponding user ID are always equal, and the user and login ID types support conversion and comparison to facilitate their use in this context. Tokens are now associated with logins, not users. The currently-acting identity is passed down into app types as a login, not a user, and then resolved to a user where appropriate within the app methods. As a side effect, the `GET /api/boot` method now returns a `login` key instead of a `user` key. The structure of the nested value is unchanged. --- ...c457fd85d122664028549a71b0f7f74f8899523271.json | 38 ++++++ ...578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json | 50 ------- ...22966d85ded0cbd27125093208d7f7f0aabb0ea61b.json | 50 ------- ...6678c9709a8637630faa759d404ebd4eb2545dab72.json | 38 ------ ...4f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json | 44 ------- ...38efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json | 38 ++++++ ...bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json | 12 ++ ...38fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json | 32 +++++ ...0c38e4cd431247831fd9f51fa0db77098cf872411e.json | 44 +++++++ ...ac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json | 38 ++++++ ...c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json | 20 --- docs/api/boot.md | 9 +- src/app.rs | 11 +- src/boot/handlers/boot/mod.rs | 8 +- src/boot/handlers/boot/test.rs | 4 +- src/conversation/handlers/send/mod.rs | 7 +- src/conversation/handlers/send/test.rs | 2 +- src/event/handlers/stream/test/invite.rs | 4 +- src/event/handlers/stream/test/setup.rs | 2 +- src/event/handlers/stream/test/token.rs | 4 +- src/invite/app.rs | 48 ++++++- src/invite/handlers/accept/test.rs | 4 +- src/invite/handlers/issue/mod.rs | 2 +- src/invite/handlers/issue/test.rs | 2 +- src/lib.rs | 1 + src/login/app.rs | 114 ++++++++++++++++ src/login/handlers/login/mod.rs | 51 ++++++++ src/login/handlers/login/test.rs | 114 ++++++++++++++++ src/login/handlers/logout/mod.rs | 51 ++++++++ src/login/handlers/logout/test.rs | 71 ++++++++++ src/login/handlers/mod.rs | 7 + src/login/handlers/password/mod.rs | 56 ++++++++ src/login/handlers/password/test.rs | 47 +++++++ src/login/id.rs | 27 ++++ src/login/mod.rs | 13 ++ src/login/repo.rs | 145 +++++++++++++++++++++ src/message/app.rs | 121 ++++++++++++----- src/message/handlers/delete/mod.rs | 11 +- src/message/handlers/delete/test.rs | 8 +- src/message/history.rs | 15 ++- src/message/repo.rs | 10 +- src/routes.rs | 8 +- src/setup/app.rs | 4 +- src/test/fixtures/cookie.rs | 14 +- src/test/fixtures/identity.rs | 6 +- src/test/fixtures/invite.rs | 4 +- src/test/fixtures/login.rs | 21 +++ src/test/fixtures/message.rs | 4 +- src/test/fixtures/mod.rs | 1 + src/test/fixtures/user.rs | 25 +--- src/test/verify/identity.rs | 6 +- src/test/verify/login.rs | 10 +- src/test/verify/token.rs | 8 +- src/token/app.rs | 118 +---------------- src/token/extract/identity.rs | 4 +- src/token/mod.rs | 12 +- src/token/repo/auth.rs | 105 --------------- src/token/repo/mod.rs | 1 - src/token/repo/token.rs | 43 +++--- src/user/app.rs | 14 +- src/user/create.rs | 28 ++-- src/user/handlers/login/mod.rs | 56 -------- src/user/handlers/login/test.rs | 110 ---------------- src/user/handlers/logout/mod.rs | 53 -------- src/user/handlers/logout/test.rs | 72 ---------- src/user/handlers/mod.rs | 7 - src/user/handlers/password/mod.rs | 58 --------- src/user/handlers/password/test.rs | 47 ------- src/user/history.rs | 26 ++-- src/user/id.rs | 17 +++ src/user/mod.rs | 3 +- src/user/repo.rs | 89 ++++++------- ui/lib/session.svelte.js | 8 +- 73 files changed, 1265 insertions(+), 1090 deletions(-) create mode 100644 .sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json delete mode 100644 .sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json delete mode 100644 .sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json delete mode 100644 .sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json delete mode 100644 .sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json create mode 100644 .sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json create mode 100644 .sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json create mode 100644 .sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json create mode 100644 .sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json create mode 100644 .sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json delete mode 100644 .sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json create mode 100644 src/login/app.rs create mode 100644 src/login/handlers/login/mod.rs create mode 100644 src/login/handlers/login/test.rs create mode 100644 src/login/handlers/logout/mod.rs create mode 100644 src/login/handlers/logout/test.rs create mode 100644 src/login/handlers/mod.rs create mode 100644 src/login/handlers/password/mod.rs create mode 100644 src/login/handlers/password/test.rs create mode 100644 src/login/id.rs create mode 100644 src/login/mod.rs create mode 100644 src/login/repo.rs create mode 100644 src/test/fixtures/login.rs delete mode 100644 src/token/repo/auth.rs delete mode 100644 src/user/handlers/login/mod.rs delete mode 100644 src/user/handlers/login/test.rs delete mode 100644 src/user/handlers/logout/mod.rs delete mode 100644 src/user/handlers/logout/test.rs delete mode 100644 src/user/handlers/mod.rs delete mode 100644 src/user/handlers/password/mod.rs delete mode 100644 src/user/handlers/password/test.rs (limited to 'src/token/mod.rs') diff --git a/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json new file mode 100644 index 0000000..ca93083 --- /dev/null +++ b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.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: login::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: login::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": "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-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json deleted file mode 100644 index 99a05e3..0000000 --- a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "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-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json b/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json deleted file mode 100644 index 1a4389f..0000000 --- a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json +++ /dev/null @@ -1,44 +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 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" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509" -} 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-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json new file mode 100644 index 0000000..789151d --- /dev/null +++ b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "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: 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_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "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 | | :----- | :----- | :--------------------------------------- | diff --git a/src/app.rs b/src/app.rs index ab8da7e..d0ffcc0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, identity: Identity) -> Result, identity: Identity) -> Result Result { 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 { + pub async fn issue(&self, issuer: &Login, issued_at: &DateTime) -> Result { + 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?; @@ -93,6 +105,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 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}")] 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, ) -> Result, 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); } diff --git a/src/lib.rs b/src/lib.rs index b3299d7..f05cce3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { + 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 { + 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 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/login/handlers/login/mod.rs b/src/login/handlers/login/mod.rs new file mode 100644 index 0000000..6591984 --- /dev/null +++ b/src/login/handlers/login/mod.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name, + password::Password, token::extract::IdentityCookie, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Empty), Error> { + let secret = app + .logins() + .with_password(&request.name, &request.password, &now) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, Empty)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + // not error::Unauthorized due to differing messaging + (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/handlers/login/test.rs b/src/login/handlers/login/test.rs new file mode 100644 index 0000000..f3911d0 --- /dev/null +++ b/src/login/handlers/login/test.rs @@ -0,0 +1,114 @@ +use axum::extract::{Json, State}; + +use crate::{ + empty::Empty, + login::app::LoginError, + test::{fixtures, verify}, +}; + +#[tokio::test] +async fn correct_credentials() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let logged_in_at = fixtures::now(); + let request = super::Request { + name: name.clone(), + password, + }; + let (identity, Empty) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + 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] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let logged_in_at = fixtures::now(); + let (name, password) = fixtures::user::propose(); + let request = super::Request { + name: name.clone(), + password, + }; + let super::Error(error) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password fails"); + + // Verify the return value's basic structure + + assert!(matches!(error, LoginError::Rejected)); +} + +#[tokio::test] +async fn incorrect_password() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::user::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::now(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: login.name, + password: fixtures::user::propose_password(), + }; + let super::Error(error) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, LoginError::Rejected)); +} + +#[tokio::test] +async fn token_expires() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::ancient(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { name, password }; + let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + app.tokens() + .expire(&fixtures::now()) + .await + .expect("expiring tokens never fails"); + + verify::token::invalid(&app, &secret).await; +} diff --git a/src/login/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs new file mode 100644 index 0000000..73efe73 --- /dev/null +++ b/src/login/handlers/logout/mod.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, State}, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + empty::Empty, + error::{Internal, Unauthorized}, + token::{app, extract::IdentityCookie}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityCookie, + Json(_): Json, +) -> Result<(IdentityCookie, Empty), Error> { + if let Some(secret) = identity.secret() { + let identity = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&identity.token).await?; + } + + let identity = identity.clear(); + Ok((identity, Empty)) +} + +// This forces the only valid request to be `{}`, and not the infinite +// variation allowed when there's no body extractor. +#[derive(Default, serde::Deserialize)] +pub struct Request {} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::ValidateError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::ValidateError::InvalidToken => Unauthorized.into_response(), + app::ValidateError::Name(_) | app::ValidateError::Database(_) => { + Internal::from(error).into_response() + } + } + } +} diff --git a/src/login/handlers/logout/test.rs b/src/login/handlers/logout/test.rs new file mode 100644 index 0000000..e7b7dd4 --- /dev/null +++ b/src/login/handlers/logout/test.rs @@ -0,0 +1,71 @@ +use axum::extract::{Json, State}; + +use crate::{ + empty::Empty, + test::{fixtures, verify}, + token::app, +}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + 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; + + // Call the endpoint + + let (response_identity, Empty) = super::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + Json::default(), + ) + .await + .expect("logged out with a valid token"); + + // Verify the return value's basic structure + assert!(response_identity.secret().is_none()); + + // Verify the semantics + verify::identity::invalid(&app, &identity).await; +} + +#[tokio::test] +async fn no_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let (identity, Empty) = super::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect("logged out with no token succeeds"); + + // Verify the return value's basic structure + + assert!(identity.secret().is_none()); +} + +#[tokio::test] +async fn invalid_token() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::fictitious(); + let super::Error(error) = + super::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect_err("logged out with an invalid token fails"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/login/handlers/mod.rs b/src/login/handlers/mod.rs new file mode 100644 index 0000000..24ee7f9 --- /dev/null +++ b/src/login/handlers/mod.rs @@ -0,0 +1,7 @@ +mod login; +mod logout; +pub mod password; + +pub use login::handler as login; +pub use logout::handler as logout; +pub use password::handler as change_password; diff --git a/src/login/handlers/password/mod.rs b/src/login/handlers/password/mod.rs new file mode 100644 index 0000000..94c7fb4 --- /dev/null +++ b/src/login/handlers/password/mod.rs @@ -0,0 +1,56 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + empty::Empty, + error::Internal, + login::app, + password::Password, + token::extract::{Identity, IdentityCookie}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: Identity, + cookie: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Empty), Error> { + let secret = app + .logins() + .change_password(&identity.login, &request.password, &request.to, &now) + .await + .map_err(Error)?; + let cookie = cookie.set(secret); + Ok((cookie, Empty)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub password: Password, + pub to: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + (StatusCode::BAD_REQUEST, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/handlers/password/test.rs b/src/login/handlers/password/test.rs new file mode 100644 index 0000000..ba2f28f --- /dev/null +++ b/src/login/handlers/password/test.rs @@ -0,0 +1,47 @@ +use axum::extract::{Json, State}; + +use crate::{ + empty::Empty, + test::{fixtures, verify}, +}; + +#[tokio::test] +async fn password_change() { + // Set up the environment + let app = fixtures::scratch_app().await; + let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; + let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; + + // Call the endpoint + let (name, password) = creds; + let to = fixtures::user::propose_password(); + let request = super::Request { + password: password.clone(), + to: to.clone(), + }; + let (new_cookie, Empty) = super::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + cookie.clone(), + Json(request), + ) + .await + .expect("changing passwords succeeds"); + + // Verify that we have a new session + assert_ne!(cookie.secret(), new_cookie.secret()); + + // Verify that we're still ourselves + 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; + + // Verify that our original password is no longer valid + verify::login::invalid_login(&app, &name, &password).await; + + // Verify that our new password is valid + verify::login::valid_login(&app, &name, &to).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 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 for Id { + fn from(user: user::Id) -> Self { + Self::from(String::from(user)) + } +} + +impl PartialEq 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 NotFound for Result { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result, 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 { - 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::>(), - ); + 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::>(), + ); + + 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 for SendError { } } +impl From 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 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 { 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(&self, sequence: S) -> Option + where + S: Into, + { + 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 { 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 { - 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 { // UI routes that can be accessed before the administrator completes setup. @@ -27,8 +27,8 @@ pub fn routes(app: &App) -> Router { // 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 { .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::().to_string().into() } -fn propose_name() -> Name { +pub(crate) fn propose_name() -> Name { rand::random::().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 { - 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 { - 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 { 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( @@ -207,34 +121,10 @@ 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 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 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 NotFound for Result { - type Ok = T; - type Error = LoadError; - - fn optional(self) -> Result, 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, sqlx::Error> { - let user = user.id(); + pub async fn revoke_all(&mut self, login: &Login) -> Result, 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 { + ) -> Result { 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 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 { 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::>()); + broadcaster.broadcast(user.events().map(Event::from).collect::>()); } - pub fn user(&self) -> &History { - &self.user + pub fn login(&self) -> &Login { + &self.login } } diff --git a/src/user/handlers/login/mod.rs b/src/user/handlers/login/mod.rs deleted file mode 100644 index d3e0e8c..0000000 --- a/src/user/handlers/login/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::Internal, - name::Name, - password::Password, - token::{app, extract::IdentityCookie}, -}; - -#[cfg(test)] -mod test; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .tokens() - .login(&request.name, &request.password, &now) - .await - .map_err(Error)?; - let identity = identity.set(secret); - Ok((identity, Empty)) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub name: Name, - pub password: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::LoginError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - // not error::Unauthorized due to differing messaging - (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/user/handlers/login/test.rs b/src/user/handlers/login/test.rs deleted file mode 100644 index 56fc2c4..0000000 --- a/src/user/handlers/login/test.rs +++ /dev/null @@ -1,110 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::{fixtures, verify}, - token::app, -}; - -#[tokio::test] -async fn correct_credentials() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let logged_in_at = fixtures::now(); - let request = super::Request { - name: name.clone(), - password, - }; - let (identity, Empty) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - - // Verify the return value's basic structure - - verify::identity::valid_for_name(&app, &identity, &name).await; -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let logged_in_at = fixtures::now(); - let (name, password) = fixtures::user::propose(); - let request = super::Request { - name: name.clone(), - password, - }; - let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password fails"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn incorrect_password() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::user::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::now(); - let identity = fixtures::cookie::not_logged_in(); - let request = super::Request { - name: login.name, - password: fixtures::user::propose_password(), - }; - let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn token_expires() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::ancient(); - let identity = fixtures::cookie::not_logged_in(); - let request = super::Request { name, password }; - let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let expired_at = fixtures::now(); - app.tokens() - .expire(&expired_at) - .await - .expect("expiring tokens never fails"); - - verify::token::invalid(&app, &secret).await; -} diff --git a/src/user/handlers/logout/mod.rs b/src/user/handlers/logout/mod.rs deleted file mode 100644 index f759451..0000000 --- a/src/user/handlers/logout/mod.rs +++ /dev/null @@ -1,53 +0,0 @@ -use axum::{ - extract::{Json, State}, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::{Internal, Unauthorized}, - token::{app, extract::IdentityCookie}, -}; - -#[cfg(test)] -mod test; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(_): Json, -) -> 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 = identity.clear(); - Ok((identity, Empty)) -} - -// This forces the only valid request to be `{}`, and not the infinite -// variation allowed when there's no body extractor. -#[derive(Default, serde::Deserialize)] -pub struct Request {} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::ValidateError); - -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::Name(_) | app::ValidateError::Database(_) => { - Internal::from(error).into_response() - } - } - } -} diff --git a/src/user/handlers/logout/test.rs b/src/user/handlers/logout/test.rs deleted file mode 100644 index 8ad4853..0000000 --- a/src/user/handlers/logout/test.rs +++ /dev/null @@ -1,72 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::{fixtures, verify}, - token::app, -}; - -#[tokio::test] -async fn successful() { - // Set up the environment - - let app = fixtures::scratch_app().await; - 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 - - let (response_identity, Empty) = super::handler( - State(app.clone()), - fixtures::now(), - identity.clone(), - Json::default(), - ) - .await - .expect("logged out with a valid token"); - - // Verify the return value's basic structure - assert!(response_identity.secret().is_none()); - - // Verify the semantics - verify::token::invalid(&app, &secret).await; -} - -#[tokio::test] -async fn no_identity() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let (identity, Empty) = super::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect("logged out with no token succeeds"); - - // Verify the return value's basic structure - - assert!(identity.secret().is_none()); -} - -#[tokio::test] -async fn invalid_token() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::fictitious(); - let super::Error(error) = - super::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect_err("logged out with an invalid token fails"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::ValidateError::InvalidToken)); -} diff --git a/src/user/handlers/mod.rs b/src/user/handlers/mod.rs deleted file mode 100644 index 5cadbb5..0000000 --- a/src/user/handlers/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod login; -mod logout; -mod password; - -pub use login::handler as login; -pub use logout::handler as logout; -pub use password::handler as change_password; diff --git a/src/user/handlers/password/mod.rs b/src/user/handlers/password/mod.rs deleted file mode 100644 index 5e69c1c..0000000 --- a/src/user/handlers/password/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::Internal, - password::Password, - token::{ - app, - extract::{Identity, IdentityCookie}, - }, -}; - -#[cfg(test)] -mod test; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: Identity, - cookie: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .tokens() - .change_password(&identity.user, &request.password, &request.to, &now) - .await - .map_err(Error)?; - let cookie = cookie.set(secret); - Ok((cookie, Empty)) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub password: Password, - pub to: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::LoginError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - (StatusCode::BAD_REQUEST, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/user/handlers/password/test.rs b/src/user/handlers/password/test.rs deleted file mode 100644 index 81020a1..0000000 --- a/src/user/handlers/password/test.rs +++ /dev/null @@ -1,47 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::{fixtures, verify}, -}; - -#[tokio::test] -async fn password_change() { - // Set up the environment - let app = fixtures::scratch_app().await; - let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; - let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; - - // Call the endpoint - let (name, password) = creds; - let to = fixtures::user::propose_password(); - let request = super::Request { - password: password.clone(), - to: to.clone(), - }; - let (new_cookie, Empty) = super::handler( - State(app.clone()), - fixtures::now(), - identity.clone(), - cookie.clone(), - Json(request), - ) - .await - .expect("changing passwords succeeds"); - - // Verify that we have a new session - 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 that our original token is no longer valid - verify::identity::invalid(&app, &cookie).await; - - // Verify that our original password is no longer valid - verify::login::invalid_login(&app, &name, &password).await; - - // Verify that our new password is valid - verify::login::valid_login(&app, &name, &to).await; -} 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 { - self.events().collect() + pub fn as_of(&self, sequence: S) -> Option + where + S: Into, + { + 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; +// 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 for Id { + fn from(login: login::Id) -> Self { + Self::from(String::from(login)) + } +} + +impl PartialEq 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 { - 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 { + 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, LoadError> { @@ -163,3 +151,16 @@ pub enum LoadError { Database(#[from] sqlx::Error), Name(#[from] name::Error), } + +impl NotFound for Result { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result, 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, -- cgit v1.2.3