diff options
83 files changed, 1357 insertions, 908 deletions
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-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json new file mode 100644 index 0000000..37a6dd3 --- /dev/null +++ b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8" +} diff --git a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json index 1be8e07..937b07e 100644 --- a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json +++ b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n delete\n from token\n where id = $1\n returning id as \"id: Id\"\n ", + "query": "\n delete\n from token\n where login = $1\n returning id as \"id: Id\"\n ", "describe": { "columns": [ { @@ -16,5 +16,5 @@ false ] }, - "hash": "b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f" + "hash": "0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d" } diff --git a/.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json b/.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json deleted file mode 100644 index 182f996..0000000 --- a/.sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into token (id, secret, user, issued_at, last_used_at)\n values ($1, $2, $3, $4, $4)\n returning secret as \"secret!: Secret\"\n ", - "describe": { - "columns": [ - { - "name": "secret!: Secret", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false - ] - }, - "hash": "0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c" -} diff --git a/.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json b/.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json deleted file mode 100644 index c02667e..0000000 --- a/.sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update user\n set password_hash = $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": "27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75" -} diff --git a/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json new file mode 100644 index 0000000..c9edfc9 --- /dev/null +++ b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from token\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4" +} diff --git a/.sqlx/query-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-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json b/.sqlx/query-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json deleted file mode 100644 index 1657efa..0000000 --- a/.sqlx/query-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n delete\n from token\n where user = $1\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e" -} diff --git a/.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json b/.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json deleted file mode 100644 index d1c2732..0000000 --- a/.sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n user as \"user: user::Id\"\n ", - "describe": { - "columns": [ - { - "name": "token: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "user: user::Id", - "ordinal": 1, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false - ] - }, - "hash": "684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb" -} diff --git a/.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json b/.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json deleted file mode 100644 index 8f9be21..0000000 --- a/.sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from user\n where 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_hash: StoredHash", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b" -} diff --git a/.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json b/.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json new file mode 100644 index 0000000..012274a --- /dev/null +++ b/.sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into login (id, display_name, canonical_name, password)\n values ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0" +} diff --git a/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json b/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json index 45177f3..cbe1cdf 100644 --- a/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json +++ b/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from user\n where created_sequence <= $1\n order by canonical_name\n ", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where user.created_sequence > $1\n ", "describe": { "columns": [ { @@ -40,5 +40,5 @@ false ] }, - "hash": "9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2" + "hash": "8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c" } 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-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json b/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json index 31c14de..1025308 100644 --- a/.sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json +++ b/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n invite.id as \"invite_id: Id\",\n issuer.id as \"issuer_id: user::Id\",\n issuer.display_name as \"issuer_name: nfc::String\",\n invite.issued_at as \"invite_issued_at: DateTime\"\n from invite\n join user as issuer on (invite.issuer = issuer.id)\n where invite.id = $1\n ", + "query": "\n select\n invite.id as \"invite_id: Id\",\n issuer.id as \"issuer_id: user::Id\",\n issuer_login.display_name as \"issuer_name: nfc::String\",\n invite.issued_at as \"invite_issued_at: DateTime\"\n from invite\n join user as issuer on (invite.issuer = issuer.id)\n join login as issuer_login on (issuer.id = issuer_login.id)\n where invite.id = $1\n ", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183" + "hash": "963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477" } 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-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json b/.sqlx/query-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json deleted file mode 100644 index b7e6c1b..0000000 --- a/.sqlx/query-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from user\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_hash: StoredHash", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416" -} diff --git a/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json b/.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json index 0926b67..e56faa9 100644 --- a/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json +++ b/.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json @@ -1,10 +1,10 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from user\n where id = $1\n ", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where user.created_sequence <= $1\n order by canonical_name\n ", "describe": { "columns": [ { - "name": "id: user::Id", + "name": "id: Id", "ordinal": 0, "type_info": "Text" }, @@ -40,5 +40,5 @@ false ] }, - "hash": "9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281" + "hash": "be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa" } diff --git a/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json index beacb24..789151d 100644 --- a/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json +++ b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from user\n where created_sequence > $1\n ", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_at as \"created_at: DateTime\",\n user.created_sequence as \"created_sequence: Sequence\"\n from user\n join login using (id)\n where id = $1\n ", "describe": { "columns": [ { @@ -19,14 +19,14 @@ "type_info": "Text" }, { - "name": "created_sequence: Sequence", + "name": "created_at: DateTime", "ordinal": 3, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "created_at: DateTime", + "name": "created_sequence: Sequence", "ordinal": 4, - "type_info": "Text" + "type_info": "Integer" } ], "parameters": { @@ -40,5 +40,5 @@ false ] }, - "hash": "9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454" + "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-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json b/.sqlx/query-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json new file mode 100644 index 0000000..6050e22 --- /dev/null +++ b/.sqlx/query-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into user (id, created_sequence, created_at)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa" +} diff --git a/.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json b/.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json deleted file mode 100644 index 9d1bc77..0000000 --- a/.sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into user (id, display_name, canonical_name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5, $6)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844" -} 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/migrations/20250822213537_split_user.sql b/migrations/20250822213537_split_user.sql new file mode 100644 index 0000000..1dbbad6 --- /dev/null +++ b/migrations/20250822213537_split_user.sql @@ -0,0 +1,195 @@ +-- First, migrate the table we're actually interested in - `user`. This splits it into two parts: +-- +-- * login, which is concerned with authentication data (and is non-synchronized, +-- though at the DB that doesn't matter much), and +-- * user, which is concerned with chat data (and is synchronized, ditto). + +alter table user + rename to old_user; + +create table login ( + id text + not null + primary key, + display_name text + not null, + canonical_name text + not null + unique, + password text + not null +); + +insert into + login (id, display_name, canonical_name, password) +select + id, + display_name, + canonical_name, + password_hash as password +from + old_user; + +create table user ( + id text + not null + primary key + references login (id), + created_at text + not null, + created_sequence bigint + not null + unique +); + +insert into + user (id, created_at, created_sequence) +select + id, + created_at, + created_sequence +from + old_user; + +-- Now, recreate the entire rest of the owl. Everything that referenced the original `user` table _except tokens_ +-- references the user table. Tokens, which are authentication data, reference the login (authn) table. + +alter table token + rename to old_token; + +create table token ( + id text + not null + primary key, + secret text + not null + unique, + login text + not null + references login (id), + issued_at text + not null, + last_used_at text + not null +); + +insert into + token (id, secret, login, issued_at, last_used_at) +select + id, + secret, + user as login, + issued_at, + last_used_at +from + old_token; + +alter table invite + rename to old_invite; + +create table invite ( + id text + primary key + not null, + issuer text + not null + references user (id), + issued_at text + not null +); + +insert into + invite (id, issuer, issued_at) +select + id, + issuer, + issued_at +from + old_invite; + +alter table message + rename to old_message; + +create table message ( + id text + not null + primary key, + conversation text + not null + references conversation (id), + sender text + not null + references user (id), + body text + null, + sent_at text + not null, + sent_sequence bigint + unique + not null, + last_sequence bigint + not null +); + +insert into + message (id, conversation, sender, body, sent_at, sent_sequence, last_sequence) +select + id, + conversation, + sender, + body, + sent_at, + sent_sequence, + last_sequence +from + old_message; + +alter table message_deleted + rename to old_message_deleted; + +create table message_deleted ( + id text + not null + primary key + references message (id), + deleted_at text + not null, + deleted_sequence bigint + unique + not null +); + +insert into + message_deleted (id, deleted_at, deleted_sequence) +select + id, + deleted_at, + deleted_sequence +from + message_deleted; + +drop table old_message_deleted; +drop table old_message; +drop table old_invite; +drop table old_token; +drop table old_user; + +create index token_issued_at + on token (issued_at); +create index token_last_used_at + on token (last_used_at); +create index token_login + on token (login); + +create index invite_issued_at + on invite (issued_at); + +create index message_sent_at + on message (sent_at); +create index message_conversation + on message (conversation); +create index message_last_sequence + on message (last_sequence); + +create index message_deleted_deleted_at + on message_deleted (deleted_at); @@ -5,6 +5,7 @@ use crate::{ conversation::app::Conversations, event::{self, app::Events}, invite::app::Invites, + login::app::Logins, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, @@ -49,9 +50,8 @@ impl App { Invites::new(&self.db, &self.events) } - #[cfg(test)] - pub const fn users(&self) -> Users<'_> { - Users::new(&self.db, &self.events) + pub const fn logins(&self) -> Logins<'_> { + Logins::new(&self.db, &self.token_events) } pub const fn messages(&self) -> Messages<'_> { @@ -65,4 +65,9 @@ impl App { pub const fn tokens(&self) -> Tokens<'_> { Tokens::new(&self.db, &self.token_events) } + + #[cfg(test)] + pub const fn users(&self) -> Users<'_> { + Users::new(&self.db, &self.events) + } } diff --git a/src/boot/handlers/boot/mod.rs b/src/boot/handlers/boot/mod.rs index 49691f7..3e022b1 100644 --- a/src/boot/handlers/boot/mod.rs +++ b/src/boot/handlers/boot/mod.rs @@ -7,8 +7,8 @@ use axum::{ use serde::Serialize; use crate::{ - app::App, boot::Snapshot, error::Internal, event::Heartbeat, token::extract::Identity, - user::User, + app::App, boot::Snapshot, error::Internal, event::Heartbeat, login::Login, + token::extract::Identity, }; #[cfg(test)] @@ -19,7 +19,7 @@ pub async fn handler(State(app): State<App>, identity: Identity) -> Result<Respo let heartbeat = Heartbeat::TIMEOUT; Ok(Response { - user: identity.user, + login: identity.login, heartbeat, snapshot, }) @@ -27,7 +27,7 @@ pub async fn handler(State(app): State<App>, identity: Identity) -> Result<Respo #[derive(serde::Serialize)] pub struct Response { - pub user: User, + pub login: Login, #[serde(serialize_with = "as_seconds")] pub heartbeat: Duration, #[serde(flatten)] diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs index c7c511a..cb50442 100644 --- a/src/boot/handlers/boot/test.rs +++ b/src/boot/handlers/boot/test.rs @@ -12,7 +12,7 @@ async fn returns_identity() { .await .expect("boot always succeeds"); - assert_eq!(viewer.user, response.user); + assert_eq!(viewer.login, response.login); } #[tokio::test] @@ -33,7 +33,7 @@ async fn includes_users() { .filter_map(fixtures::event::user::created) .exactly_one() .expect("only one user has been created"); - assert_eq!(spectator, created.user) + assert_eq!(spectator.id, created.user.id); } #[tokio::test] diff --git a/src/conversation/handlers/send/mod.rs b/src/conversation/handlers/send/mod.rs index 1c8ac63..c8be59c 100644 --- a/src/conversation/handlers/send/mod.rs +++ b/src/conversation/handlers/send/mod.rs @@ -25,7 +25,7 @@ pub async fn handler( ) -> Result<Response, Error> { let message = app .messages() - .send(&conversation, &identity.user, &sent_at, &request.body) + .send(&conversation, &identity.login, &sent_at, &request.body) .await?; Ok(Response(message)) @@ -57,7 +57,10 @@ impl IntoResponse for Error { SendError::ConversationNotFound(_) | SendError::ConversationDeleted(_) => { NotFound(error).into_response() } - SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(), + SendError::SenderNotFound(_) + | SendError::SenderDeleted(_) + | SendError::Name(_) + | SendError::Database(_) => Internal::from(error).into_response(), } } } diff --git a/src/conversation/handlers/send/test.rs b/src/conversation/handlers/send/test.rs index bd32510..8863090 100644 --- a/src/conversation/handlers/send/test.rs +++ b/src/conversation/handlers/send/test.rs @@ -55,7 +55,7 @@ async fn messages_in_order() { .await { assert_eq!(*sent_at, event.at()); - assert_eq!(sender.user.id, event.message.sender); + assert_eq!(sender.login.id, event.message.sender); assert_eq!(body, event.message.body); } } diff --git a/src/event/handlers/stream/mod.rs b/src/event/handlers/stream/mod.rs index d0d3f08..63bfff3 100644 --- a/src/event/handlers/stream/mod.rs +++ b/src/event/handlers/stream/mod.rs @@ -27,7 +27,7 @@ pub async fn handler( let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner); let stream = app.events().subscribe(resume_at).await?; - let stream = app.tokens().limit_stream(identity.token, stream).await?; + let stream = app.tokens().limit_stream(&identity.token, stream).await?; Ok(Response(stream)) } diff --git a/src/event/handlers/stream/test/invite.rs b/src/event/handlers/stream/test/invite.rs index f2d6950..d4ac82d 100644 --- a/src/event/handlers/stream/test/invite.rs +++ b/src/event/handlers/stream/test/invite.rs @@ -33,7 +33,7 @@ async fn accepting_invite() { .accept(&invite.id, &name, &password, &fixtures::now()) .await .expect("accepting an invite succeeds"); - let (_, joiner) = app + let joiner = app .tokens() .validate(&joiner, &fixtures::now()) .await @@ -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)) + .filter(|event| future::ready(event.user.id == joiner.login.id)) .next() .expect_some("a login created event is sent") .await; @@ -67,7 +67,7 @@ async fn previously_accepted_invite() { .accept(&invite.id, &name, &password, &fixtures::now()) .await .expect("accepting an invite succeeds"); - let (_, joiner) = app + let joiner = app .tokens() .validate(&joiner, &fixtures::now()) .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)) + .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 297162e..3f01ccb 100644 --- a/src/event/handlers/stream/test/setup.rs +++ b/src/event/handlers/stream/test/setup.rs @@ -22,7 +22,7 @@ async fn previously_completed() { .initial(&name, &password, &fixtures::now()) .await .expect("initial setup in an empty app succeeds"); - let (_, owner) = app + let owner = app .tokens() .validate(&secret, &fixtures::now()) .await @@ -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)) + .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 1c85562..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, repo::Provider as _}, + token::{Secret, Token, repo::Provider as _}, user::{ - User, + self, create::{self, Create}, + repo::{LoadError, Provider as _}, }, }; @@ -25,9 +27,19 @@ impl<'a> Invites<'a> { Self { db, events } } - pub async fn issue(&self, issuer: &User, issued_at: &DateTime) -> Result<Invite, sqlx::Error> { + pub async fn issue(&self, issuer: &Login, issued_at: &DateTime) -> Result<Invite, Error> { + let issuer_not_found = || Error::IssuerNotFound(issuer.id.clone().into()); + let issuer_deleted = || Error::IssuerDeleted(issuer.id.clone().into()); + let mut tx = self.db.begin().await?; - let invite = tx.invites().create(issuer, issued_at).await?; + let issuer = tx + .users() + .by_login(issuer) + .await + .not_found(issuer_not_found)?; + let now = tx.sequence().current().await?; + let issuer = issuer.as_of(now).ok_or_else(issuer_deleted)?; + let invite = tx.invites().create(&issuer, issued_at).await?; tx.commit().await?; Ok(invite) @@ -71,7 +83,9 @@ impl<'a> Invites<'a> { .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(stored.user(), accepted_at).await?; + let login = stored.login(); + let (token, secret) = Token::generate(login, accepted_at); + tx.tokens().create(&token, &secret).await?; tx.commit().await?; stored.publish(self.events); @@ -92,6 +106,28 @@ impl<'a> Invites<'a> { } #[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("issuing user {0} not found")] + IssuerNotFound(user::Id), + #[error("issuing user {0} deleted")] + IssuerDeleted(user::Id), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<user::repo::LoadError> for Error { + fn from(error: LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + +#[derive(Debug, thiserror::Error)] pub enum AcceptError { #[error("invite not found: {0}")] NotFound(Id), diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs index adc7aa4..283ec76 100644 --- a/src/invite/handlers/accept/test.rs +++ b/src/invite/handlers/accept/test.rs @@ -1,6 +1,11 @@ use axum::extract::{Json, Path, State}; -use crate::{empty::Empty, invite::app::AcceptError, name::Name, test::fixtures}; +use crate::{ + empty::Empty, + invite::app::AcceptError, + name::Name, + test::{fixtures, verify}, +}; #[tokio::test] async fn valid_invite() { @@ -34,29 +39,16 @@ async fn valid_invite() { // Verify that the issued token is valid - let secret = identity - .secret() - .expect("newly-issued identity has a token secret"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("newly-issued identity cookie is valid"); - assert_eq!(name, login.name); + verify::identity::valid_for_name(&app, &identity, &name).await; // 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"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("validating a newly-issued token secret succeeds"); - assert_eq!(name, login.name); + verify::token::valid_for_name(&app, &secret, &name).await; } #[tokio::test] diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs index 6085f7a..4ac74cc 100644 --- a/src/invite/handlers/issue/mod.rs +++ b/src/invite/handlers/issue/mod.rs @@ -13,7 +13,7 @@ pub async fn handler( identity: Identity, _: Json<Request>, ) -> Result<Json<Invite>, Internal> { - let invite = app.invites().issue(&identity.user, &issued_at).await?; + let invite = app.invites().issue(&identity.login, &issued_at).await?; Ok(Json(invite)) } diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs index 2bf5400..4421705 100644 --- a/src/invite/handlers/issue/test.rs +++ b/src/invite/handlers/issue/test.rs @@ -22,6 +22,6 @@ async fn create_invite() { .expect("creating an invite always succeeds"); // Verify the response - assert_eq!(issuer.user.id, invite.issuer); + assert_eq!(issuer.login.id, invite.issuer); assert_eq!(&*issued_at, &invite.issued_at); } diff --git a/src/invite/repo.rs b/src/invite/repo.rs index 7cfa18e..934b0ce 100644 --- a/src/invite/repo.rs +++ b/src/invite/repo.rs @@ -71,10 +71,11 @@ impl Invites<'_> { select invite.id as "invite_id: Id", issuer.id as "issuer_id: user::Id", - issuer.display_name as "issuer_name: nfc::String", + issuer_login.display_name as "issuer_name: nfc::String", invite.issued_at as "invite_issued_at: DateTime" from invite join user as issuer on (invite.issuer = issuer.id) + join login as issuer_login on (issuer.id = issuer_login.id) where invite.id = $1 "#, invite, @@ -15,6 +15,7 @@ mod event; mod expire; mod id; mod invite; +mod login; mod message; mod name; mod normalize; diff --git a/src/login/app.rs b/src/login/app.rs new file mode 100644 index 0000000..77d4ac3 --- /dev/null +++ b/src/login/app.rs @@ -0,0 +1,114 @@ +use sqlx::sqlite::SqlitePool; + +use crate::{ + clock::DateTime, + db::NotFound as _, + login::{self, Login, repo::Provider as _}, + name::{self, Name}, + password::Password, + token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _}, +}; + +pub struct Logins<'a> { + db: &'a SqlitePool, + token_events: &'a Broadcaster, +} + +impl<'a> Logins<'a> { + pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self { + Self { db, token_events } + } + + pub async fn with_password( + &self, + name: &Name, + candidate: &Password, + login_at: &DateTime, + ) -> Result<Secret, LoginError> { + let mut tx = self.db.begin().await?; + let (login, password) = tx + .logins() + .by_name(name) + .await + .not_found(|| LoginError::Rejected)?; + // Split the transaction here to avoid holding the tx open (potentially blocking + // other writes) while we do the fairly expensive task of verifying the + // password. It's okay if the token issuance transaction happens some notional + // amount of time after retrieving the login, as inserting the token will fail + // if the account is deleted during that time. + tx.commit().await?; + + if password.verify(candidate)? { + let mut tx = self.db.begin().await?; + let (token, secret) = Token::generate(&login, login_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; + Ok(secret) + } else { + Err(LoginError::Rejected) + } + } + + pub async fn change_password( + &self, + login: &Login, + from: &Password, + to: &Password, + changed_at: &DateTime, + ) -> Result<Secret, LoginError> { + let mut tx = self.db.begin().await?; + let (login, password) = tx + .logins() + .by_id(&login.id) + .await + .not_found(|| LoginError::Rejected)?; + // Split the transaction here to avoid holding the tx open (potentially blocking + // other writes) while we do the fairly expensive task of verifying the + // password. It's okay if the token issuance transaction happens some notional + // amount of time after retrieving the login, as inserting the token will fail + // if the account is deleted during that time. + tx.commit().await?; + + if password.verify(from)? { + let to_hash = to.hash()?; + let (token, secret) = Token::generate(&login, changed_at); + + let mut tx = self.db.begin().await?; + tx.logins().set_password(&login, &to_hash).await?; + + let revoked = tx.tokens().revoke_all(&login).await?; + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; + + for event in revoked.into_iter().map(TokenEvent::Revoked) { + self.token_events.broadcast(event); + } + + Ok(secret) + } else { + Err(LoginError::Rejected) + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("invalid login")] + Rejected, + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} + +impl From<login::repo::LoadError> for LoginError { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} diff --git a/src/user/handlers/login/mod.rs b/src/login/handlers/login/mod.rs index d3e0e8c..6591984 100644 --- a/src/user/handlers/login/mod.rs +++ b/src/login/handlers/login/mod.rs @@ -5,13 +5,8 @@ use axum::{ }; use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::Internal, - name::Name, - password::Password, - token::{app, extract::IdentityCookie}, + app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name, + password::Password, token::extract::IdentityCookie, }; #[cfg(test)] @@ -24,8 +19,8 @@ pub async fn handler( Json(request): Json<Request>, ) -> Result<(IdentityCookie, Empty), Error> { let secret = app - .tokens() - .login(&request.name, &request.password, &now) + .logins() + .with_password(&request.name, &request.password, &now) .await .map_err(Error)?; let identity = identity.set(secret); diff --git a/src/user/handlers/login/test.rs b/src/login/handlers/login/test.rs index bdd1957..f3911d0 100644 --- a/src/user/handlers/login/test.rs +++ b/src/login/handlers/login/test.rs @@ -1,6 +1,10 @@ use axum::extract::{Json, State}; -use crate::{empty::Empty, test::fixtures, token::app}; +use crate::{ + empty::Empty, + login::app::LoginError, + test::{fixtures, verify}, +}; #[tokio::test] async fn correct_credentials() { @@ -29,15 +33,7 @@ async fn correct_credentials() { .expect("logged in with valid credentials issues an identity cookie"); // Verify the semantics - - let validated_at = fixtures::now(); - let (_, validated_login) = app - .tokens() - .validate(&secret, &validated_at) - .await - .expect("identity secret is valid"); - - assert_eq!(name, validated_login.name); + verify::token::valid_for_name(&app, &secret, &name).await; } #[tokio::test] @@ -62,7 +58,7 @@ async fn invalid_name() { // Verify the return value's basic structure - assert!(matches!(error, app::LoginError::Rejected)); + assert!(matches!(error, LoginError::Rejected)); } #[tokio::test] @@ -87,7 +83,7 @@ async fn incorrect_password() { // Verify the return value's basic structure - assert!(matches!(error, app::LoginError::Rejected)); + assert!(matches!(error, LoginError::Rejected)); } #[tokio::test] @@ -109,18 +105,10 @@ async fn token_expires() { // Verify the semantics - let expired_at = fixtures::now(); app.tokens() - .expire(&expired_at) + .expire(&fixtures::now()) .await .expect("expiring tokens never fails"); - let verified_at = fixtures::now(); - let error = app - .tokens() - .validate(&secret, &verified_at) - .await - .expect_err("validating an expired token"); - - assert!(matches!(error, app::ValidateError::InvalidToken)); + verify::token::invalid(&app, &secret).await; } diff --git a/src/user/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs index 4450e4c..73efe73 100644 --- a/src/user/handlers/logout/mod.rs +++ b/src/login/handlers/logout/mod.rs @@ -21,8 +21,8 @@ pub async fn handler( Json(_): Json<Request>, ) -> Result<(IdentityCookie, Empty), Error> { if let Some(secret) = identity.secret() { - let (token, _) = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&token).await?; + let identity = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&identity.token).await?; } let identity = identity.clear(); @@ -42,9 +42,7 @@ impl IntoResponse for Error { fn into_response(self) -> Response { let Self(error) = self; match error { - app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => { - Unauthorized.into_response() - } + app::ValidateError::InvalidToken => Unauthorized.into_response(), app::ValidateError::Name(_) | app::ValidateError::Database(_) => { Internal::from(error).into_response() } diff --git a/src/user/handlers/logout/test.rs b/src/login/handlers/logout/test.rs index 7151ddf..e7b7dd4 100644 --- a/src/user/handlers/logout/test.rs +++ b/src/login/handlers/logout/test.rs @@ -1,6 +1,10 @@ use axum::extract::{Json, State}; -use crate::{empty::Empty, test::fixtures, token::app}; +use crate::{ + empty::Empty, + test::{fixtures, verify}, + token::app, +}; #[tokio::test] async fn successful() { @@ -10,7 +14,6 @@ async fn successful() { let now = fixtures::now(); let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; - let secret = fixtures::cookie::secret(&identity); // Call the endpoint @@ -24,16 +27,10 @@ async fn successful() { .expect("logged out with a valid token"); // Verify the return value's basic structure - assert!(response_identity.secret().is_none()); // Verify the semantics - let error = app - .tokens() - .validate(&secret, &now) - .await - .expect_err("secret is invalid"); - assert!(matches!(error, app::ValidateError::InvalidToken)); + verify::identity::invalid(&app, &identity).await; } #[tokio::test] diff --git a/src/user/handlers/mod.rs b/src/login/handlers/mod.rs index 5cadbb5..24ee7f9 100644 --- a/src/user/handlers/mod.rs +++ b/src/login/handlers/mod.rs @@ -1,6 +1,6 @@ mod login; mod logout; -mod password; +pub mod password; pub use login::handler as login; pub use logout::handler as logout; diff --git a/src/user/handlers/password/mod.rs b/src/login/handlers/password/mod.rs index 5e69c1c..94c7fb4 100644 --- a/src/user/handlers/password/mod.rs +++ b/src/login/handlers/password/mod.rs @@ -9,11 +9,9 @@ use crate::{ clock::RequestedAt, empty::Empty, error::Internal, + login::app, password::Password, - token::{ - app, - extract::{Identity, IdentityCookie}, - }, + token::extract::{Identity, IdentityCookie}, }; #[cfg(test)] @@ -27,8 +25,8 @@ pub async fn handler( Json(request): Json<Request>, ) -> Result<(IdentityCookie, Empty), Error> { let secret = app - .tokens() - .change_password(&identity.user, &request.password, &request.to, &now) + .logins() + .change_password(&identity.login, &request.password, &request.to, &now) .await .map_err(Error)?; let cookie = cookie.set(secret); diff --git a/src/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>; + +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From<user::Id> for Id { + fn from(user: user::Id) -> Self { + Self::from(String::from(user)) + } +} + +impl PartialEq<user::Id> for Id { + fn eq(&self, other: &user::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Login; + +impl crate::id::Prefix for Login { + fn prefix(&self) -> &'static str { + user::id::User.prefix() + } +} diff --git a/src/login/mod.rs b/src/login/mod.rs new file mode 100644 index 0000000..bccc2af --- /dev/null +++ b/src/login/mod.rs @@ -0,0 +1,13 @@ +pub mod app; +pub mod handlers; +mod id; +pub mod repo; + +use crate::name::Name; +pub use id::Id; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct Login { + pub id: Id, + pub name: Name, +} diff --git a/src/login/repo.rs b/src/login/repo.rs new file mode 100644 index 0000000..5be91ad --- /dev/null +++ b/src/login/repo.rs @@ -0,0 +1,145 @@ +use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; + +use super::{Id, Login}; +use crate::{ + db::NotFound, + name::{self, Name}, + password::StoredHash, +}; + +pub trait Provider { + fn logins(&mut self) -> Logins<'_>; +} + +impl Provider for Transaction<'_, Sqlite> { + fn logins(&mut self) -> Logins<'_> { + Logins(self) + } +} + +pub struct Logins<'t>(&'t mut SqliteConnection); + +impl Logins<'_> { + pub async fn create( + &mut self, + login: &Login, + password: &StoredHash, + ) -> Result<(), sqlx::Error> { + let Login { id, name } = login; + let display_name = name.display(); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + insert into login (id, display_name, canonical_name, password) + values ($1, $2, $3, $4) + "#, + id, + display_name, + canonical_name, + password, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } + + pub async fn by_id(&mut self, id: &Id) -> Result<(Login, StoredHash), LoadError> { + let user = sqlx::query!( + r#" + select + id as "id: Id", + display_name, + canonical_name, + password as "password: StoredHash" + from login + where id = $1 + "#, + id, + ) + .map(|row| { + Ok::<_, LoadError>(( + Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + row.password, + )) + }) + .fetch_one(&mut *self.0) + .await??; + + Ok(user) + } + + pub async fn by_name(&mut self, name: &Name) -> Result<(Login, StoredHash), LoadError> { + let canonical_name = name.canonical(); + + let (login, password) = sqlx::query!( + r#" + select + id as "id: Id", + display_name, + canonical_name, + password as "password: StoredHash" + from login + where canonical_name = $1 + "#, + canonical_name, + ) + .map(|row| { + Ok::<_, LoadError>(( + Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + row.password, + )) + }) + .fetch_one(&mut *self.0) + .await??; + + Ok((login, password)) + } + + pub async fn set_password( + &mut self, + login: &Login, + password: &StoredHash, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + update login + set password = $1 + where id = $2 + "#, + password, + login.id, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/src/message/app.rs b/src/message/app.rs index bdc2164..9100224 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -8,8 +8,9 @@ use crate::{ conversation::{self, repo::Provider as _}, db::NotFound as _, event::{Broadcaster, Event, Sequence, repo::Provider as _}, + login::Login, name, - user::User, + user::{self, repo::Provider as _}, }; pub struct Messages<'a> { @@ -25,27 +26,35 @@ impl<'a> Messages<'a> { pub async fn send( &self, conversation: &conversation::Id, - sender: &User, + sender: &Login, sent_at: &DateTime, body: &Body, ) -> Result<Message, SendError> { - let to_not_found = || SendError::ConversationNotFound(conversation.clone()); - let to_deleted = || SendError::ConversationDeleted(conversation.clone()); + let conversation_not_found = || SendError::ConversationNotFound(conversation.clone()); + let conversation_deleted = || SendError::ConversationDeleted(conversation.clone()); + let sender_not_found = || SendError::SenderNotFound(sender.id.clone().into()); + let sender_deleted = || SendError::SenderDeleted(sender.id.clone().into()); let mut tx = self.db.begin().await?; let conversation = tx .conversations() .by_id(conversation) .await - .not_found(to_not_found)?; + .not_found(conversation_not_found)?; + let sender = tx + .users() + .by_login(sender) + .await + .not_found(sender_not_found)?; // Ordering: don't bother allocating a sequence number before we know the channel might // exist. let sent = tx.sequence().next(sent_at).await?; - let conversation = conversation.as_of(sent).ok_or_else(to_deleted)?; + let conversation = conversation.as_of(sent).ok_or_else(conversation_deleted)?; + let sender = sender.as_of(sent).ok_or_else(sender_deleted)?; let message = tx .messages() - .create(&conversation, sender, &sent, body) + .create(&conversation, &sender, &sent, body) .await?; tx.commit().await?; @@ -57,36 +66,48 @@ impl<'a> Messages<'a> { pub async fn delete( &self, - deleted_by: &User, + deleted_by: &Login, message: &Id, deleted_at: &DateTime, ) -> Result<(), DeleteError> { + let message_not_found = || DeleteError::MessageNotFound(message.clone()); + let message_deleted = || DeleteError::Deleted(message.clone()); + let deleter_not_found = || DeleteError::UserNotFound(deleted_by.id.clone().into()); + let deleter_deleted = || DeleteError::UserDeleted(deleted_by.id.clone().into()); + let not_sender = || DeleteError::NotSender(deleted_by.id.clone().into()); + let mut tx = self.db.begin().await?; let message = tx .messages() .by_id(message) .await - .not_found(|| DeleteError::NotFound(message.clone()))?; - let snapshot = message - .as_snapshot() - .ok_or_else(|| DeleteError::Deleted(message.id().clone()))?; - if snapshot.sender != deleted_by.id { - return Err(DeleteError::NotSender(deleted_by.clone())); - } + .not_found(message_not_found)?; + let deleted_by = tx + .users() + .by_login(deleted_by) + .await + .not_found(deleter_not_found)?; let deleted = tx.sequence().next(deleted_at).await?; - let message = tx.messages().delete(&message, &deleted).await?; - tx.commit().await?; + let message = message.as_of(deleted).ok_or_else(message_deleted)?; + let deleted_by = deleted_by.as_of(deleted).ok_or_else(deleter_deleted)?; - self.events.broadcast( - message - .events() - .filter(Sequence::start_from(deleted.sequence)) - .map(Event::from) - .collect::<Vec<_>>(), - ); + if message.sender == deleted_by.id { + let message = tx.messages().delete(&message, &deleted).await?; + tx.commit().await?; - Ok(()) + self.events.broadcast( + message + .events() + .filter(Sequence::start_from(deleted.sequence)) + .map(Event::from) + .collect::<Vec<_>>(), + ); + + Ok(()) + } else { + Err(not_sender()) + } } pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { @@ -99,12 +120,14 @@ impl<'a> Messages<'a> { let mut events = Vec::with_capacity(expired.len()); for message in expired { let deleted = tx.sequence().next(relative_to).await?; - let message = tx.messages().delete(&message, &deleted).await?; - events.push( - message - .events() - .filter(Sequence::start_from(deleted.sequence)), - ); + if let Some(message) = message.as_of(deleted) { + let message = tx.messages().delete(&message, &deleted).await?; + events.push( + message + .events() + .filter(Sequence::start_from(deleted.sequence)), + ); + } } tx.commit().await?; @@ -138,6 +161,10 @@ pub enum SendError { ConversationNotFound(conversation::Id), #[error("conversation {0} deleted")] ConversationDeleted(conversation::Id), + #[error("user {0} not found")] + SenderNotFound(user::Id), + #[error("user {0} deleted")] + SenderDeleted(user::Id), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] @@ -154,14 +181,40 @@ impl From<conversation::repo::LoadError> for SendError { } } +impl From<user::repo::LoadError> for SendError { + fn from(error: user::repo::LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum DeleteError { #[error("message {0} not found")] - NotFound(Id), - #[error("user {} not the message's sender", .0.id)] - NotSender(User), + MessageNotFound(Id), + #[error("user {0} not found")] + UserNotFound(user::Id), + #[error("user {0} deleted")] + UserDeleted(user::Id), + #[error("user {0} not the message's sender")] + NotSender(user::Id), #[error("message {0} deleted")] Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<user::repo::LoadError> for DeleteError { + fn from(error: user::repo::LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs index 5eac4eb..606f502 100644 --- a/src/message/handlers/delete/mod.rs +++ b/src/message/handlers/delete/mod.rs @@ -22,7 +22,7 @@ pub async fn handler( identity: Identity, ) -> Result<Response, Error> { app.messages() - .delete(&identity.user, &message, &deleted_at) + .delete(&identity.login, &message, &deleted_at) .await?; Ok(Response { id: message }) @@ -48,8 +48,13 @@ impl IntoResponse for Error { let Self(error) = self; match error { DeleteError::NotSender(_) => (StatusCode::FORBIDDEN, error.to_string()).into_response(), - DeleteError::NotFound(_) | DeleteError::Deleted(_) => NotFound(error).into_response(), - DeleteError::Database(_) => Internal::from(error).into_response(), + DeleteError::MessageNotFound(_) | DeleteError::Deleted(_) => { + NotFound(error).into_response() + } + DeleteError::UserNotFound(_) + | DeleteError::UserDeleted(_) + | DeleteError::Database(_) + | DeleteError::Name(_) => Internal::from(error).into_response(), } } } diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs index 371c7bf..d0e1794 100644 --- a/src/message/handlers/delete/test.rs +++ b/src/message/handlers/delete/test.rs @@ -11,7 +11,7 @@ pub async fn delete_message() { let sender = fixtures::identity::create(&app, &fixtures::now()).await; let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let message = - fixtures::message::send(&app, &conversation, &sender.user, &fixtures::now()).await; + fixtures::message::send(&app, &conversation, &sender.login, &fixtures::now()).await; // Send the request @@ -62,7 +62,7 @@ pub async fn delete_invalid_message_id() { // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message)); + assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message)); } #[tokio::test] @@ -160,7 +160,7 @@ pub async fn delete_purged() { // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id)); + assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message.id)); } #[tokio::test] @@ -187,6 +187,6 @@ pub async fn delete_not_sender() { // Verify the response assert!( - matches!(error, app::DeleteError::NotSender(error_sender) if deleter.user == error_sender) + matches!(error, app::DeleteError::NotSender(error_sender) if deleter.login.id == error_sender) ); } diff --git a/src/message/history.rs b/src/message/history.rs index d4d4500..2abdf2c 100644 --- a/src/message/history.rs +++ b/src/message/history.rs @@ -1,7 +1,7 @@ use itertools::Itertools as _; use super::{ - Id, Message, + Message, event::{Deleted, Event, Sent}, }; use crate::event::Sequence; @@ -13,10 +13,6 @@ pub struct History { // State interface impl History { - pub fn id(&self) -> &Id { - &self.message.id - } - // Snapshot of this message as it was when sent. (Note to the future: it's okay // if this returns a redacted or modified version of the message. If we // implement message editing by redacting the original body, then this should @@ -26,6 +22,15 @@ impl History { self.message.clone() } + pub fn as_of<S>(&self, sequence: S) -> Option<Message> + where + S: Into<Sequence>, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() + } + // Snapshot of this message as of all events recorded in this history. pub fn as_snapshot(&self) -> Option<Message> { self.events().collect() diff --git a/src/message/repo.rs b/src/message/repo.rs index 2e9700a..83bf0d5 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -180,17 +180,15 @@ impl Messages<'_> { pub async fn delete( &mut self, - message: &History, + message: &Message, deleted: &Instant, ) -> Result<History, sqlx::Error> { - let id = message.id(); - sqlx::query!( r#" insert into message_deleted (id, deleted_at, deleted_sequence) values ($1, $2, $3) "#, - id, + message.id, deleted.at, deleted.sequence, ) @@ -209,12 +207,12 @@ impl Messages<'_> { returning id as "id: Id" "#, deleted.sequence, - id, + message.id, ) .fetch_one(&mut *self.0) .await?; - let message = self.by_id(id).await?; + let message = self.by_id(&message.id).await?; Ok(message) } diff --git a/src/routes.rs b/src/routes.rs index f6b97e5..b848afb 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,7 +4,7 @@ use axum::{ routing::{delete, get, post}, }; -use crate::{app::App, boot, conversation, event, expire, invite, message, setup, ui, user}; +use crate::{app::App, boot, conversation, event, expire, invite, login, message, setup, ui}; pub fn routes(app: &App) -> Router<App> { // UI routes that can be accessed before the administrator completes setup. @@ -27,8 +27,8 @@ pub fn routes(app: &App) -> Router<App> { // API routes that require the administrator to complete setup first. let api_setup_required = Router::new() - .route("/api/auth/login", post(user::handlers::login)) - .route("/api/auth/logout", post(user::handlers::logout)) + .route("/api/auth/login", post(login::handlers::login)) + .route("/api/auth/logout", post(login::handlers::logout)) .route("/api/boot", get(boot::handlers::boot)) .route("/api/conversations", post(conversation::handlers::create)) .route( @@ -44,7 +44,7 @@ pub fn routes(app: &App) -> Router<App> { .route("/api/invite/{invite}", get(invite::handlers::get)) .route("/api/invite/{invite}", post(invite::handlers::accept)) .route("/api/messages/{message}", delete(message::handlers::delete)) - .route("/api/password", post(user::handlers::change_password)) + .route("/api/password", post(login::handlers::change_password)) // Run expiry whenever someone accesses the API. This was previously a blanket middleware // affecting the whole service, but loading the client makes a several requests before the // client can completely load, each of which was triggering expiry. There is absolutely no diff --git a/src/setup/app.rs b/src/setup/app.rs index 1856519..2a8ec30 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -6,7 +6,7 @@ use crate::{ event::Broadcaster, name::Name, password::Password, - token::{Secret, repo::Provider as _}, + token::{Secret, Token, repo::Provider as _}, user::create::{self, Create}, }; @@ -31,17 +31,20 @@ impl<'a> Setup<'a> { let validated = create.validate()?; let mut tx = self.db.begin().await?; - let stored = if tx.setup().completed().await? { - Err(Error::SetupCompleted)? + if tx.setup().completed().await? { + Err(Error::SetupCompleted) } else { - validated.store(&mut tx).await? - }; - let secret = tx.tokens().issue(stored.user(), created_at).await?; - tx.commit().await?; + let stored = validated.store(&mut tx).await?; + let login = stored.login(); + + let (token, secret) = Token::generate(login, created_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; - stored.publish(self.events); + stored.publish(self.events); - Ok(secret) + Ok(secret) + } } pub async fn completed(&self) -> Result<bool, sqlx::Error> { diff --git a/src/setup/handlers/setup/test.rs b/src/setup/handlers/setup/test.rs index 4a37690..283fe8b 100644 --- a/src/setup/handlers/setup/test.rs +++ b/src/setup/handlers/setup/test.rs @@ -1,6 +1,10 @@ use axum::extract::{Json, State}; -use crate::{empty::Empty, setup::app, test::fixtures}; +use crate::{ + empty::Empty, + setup::app, + test::{fixtures, verify}, +}; #[tokio::test] async fn fresh_instance() { @@ -21,30 +25,10 @@ async fn fresh_instance() { .expect("setup in a fresh app succeeds"); // Verify that the issued token is valid - - let secret = identity - .secret() - .expect("newly-issued identity has a token secret"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("newly-issued identity cookie is valid"); - assert_eq!(name, login.name); + verify::identity::valid_for_name(&app, &identity, &name).await; // Verify that the given credentials can log in - - let secret = app - .tokens() - .login(&name, &password, &fixtures::now()) - .await - .expect("credentials given on signup are valid"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("validating a newly-issued token secret succeeds"); - assert_eq!(name, login.name); + verify::login::valid_login(&app, &name, &password).await; } #[tokio::test] 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 84e1cf6..93e4a38 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -5,7 +5,7 @@ use crate::{ password::Password, test::fixtures, token::{ - self, + Token, extract::{Identity, IdentityCookie}, }, }; @@ -21,13 +21,10 @@ pub async fn from_cookie( validated_at: &RequestedAt, ) -> Identity { let secret = cookie.secret().expect("identity token has a secret"); - let (token, user) = app - .tokens() + app.tokens() .validate(&secret, validated_at) .await - .expect("always validates newly-issued secret"); - - Identity { token, user } + .expect("always validates newly-issued secret") } pub async fn logged_in( @@ -40,8 +37,8 @@ pub async fn logged_in( } pub fn fictitious() -> Identity { - let token = token::Id::generate(); - let user = fixtures::user::fictitious(); + let login = fixtures::login::fictitious(); + let (token, _) = Token::generate(&login, &fixtures::now()); - Identity { token, user } + Identity { token, login } } diff --git a/src/test/fixtures/invite.rs b/src/test/fixtures/invite.rs index 7a41eb6..654d1b4 100644 --- a/src/test/fixtures/invite.rs +++ b/src/test/fixtures/invite.rs @@ -2,10 +2,10 @@ use crate::{ app::App, clock::DateTime, invite::{self, Invite}, - user::User, + login::Login, }; -pub async fn issue(app: &App, issuer: &User, issued_at: &DateTime) -> Invite { +pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite { app.invites() .issue(issuer, issued_at) .await diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs new file mode 100644 index 0000000..d9aca81 --- /dev/null +++ b/src/test/fixtures/login.rs @@ -0,0 +1,21 @@ +use crate::{ + app::App, + clock::DateTime, + login::{self, Login}, + test::fixtures::user::{propose, propose_name}, +}; + +pub async fn create(app: &App, created_at: &DateTime) -> Login { + let (name, password) = propose(); + app.users() + .create(&name, &password, created_at) + .await + .expect("should always succeed if the user is actually new") +} + +pub fn fictitious() -> Login { + Login { + id: login::Id::generate(), + name: propose_name(), + } +} diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 03f8072..92ac1f5 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -4,14 +4,14 @@ use crate::{ app::App, clock::RequestedAt, conversation::Conversation, + login::Login, message::{self, Body, Message}, - user::User, }; pub async fn send( app: &App, conversation: &Conversation, - sender: &User, + sender: &Login, sent_at: &RequestedAt, ) -> Message { let body = propose(); diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 87d3fa1..3d69cfa 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -9,6 +9,7 @@ pub mod event; pub mod future; pub mod identity; pub mod invite; +pub mod login; pub mod message; pub mod user; diff --git a/src/test/fixtures/user.rs b/src/test/fixtures/user.rs index 086f866..d4d8db4 100644 --- a/src/test/fixtures/user.rs +++ b/src/test/fixtures/user.rs @@ -1,13 +1,7 @@ use faker_rand::{en_us::internet, lorem::Paragraphs}; use uuid::Uuid; -use crate::{ - app::App, - clock::RequestedAt, - name::Name, - password::Password, - user::{self, User}, -}; +use crate::{app::App, clock::RequestedAt, login::Login, name::Name, password::Password}; pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) { let (name, password) = propose(); @@ -20,19 +14,8 @@ pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, (user.name, password) } -pub async fn create(app: &App, created_at: &RequestedAt) -> User { - let (name, password) = propose(); - app.users() - .create(&name, &password, created_at) - .await - .expect("should always succeed if the login is actually new") -} - -pub fn fictitious() -> User { - User { - id: user::Id::generate(), - name: propose_name(), - } +pub async fn create(app: &App, created_at: &RequestedAt) -> Login { + super::login::create(app, created_at).await } pub fn propose() -> (Name, Password) { @@ -43,7 +26,7 @@ pub fn propose_invalid_name() -> Name { rand::random::<Paragraphs>().to_string().into() } -fn propose_name() -> Name { +pub(crate) fn propose_name() -> Name { rand::random::<internet::Username>().to_string().into() } diff --git a/src/test/mod.rs b/src/test/mod.rs index d066349..ebbbfef 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1 +1,2 @@ pub mod fixtures; +pub mod verify; diff --git a/src/test/verify/identity.rs b/src/test/verify/identity.rs new file mode 100644 index 0000000..8e2d36e --- /dev/null +++ b/src/test/verify/identity.rs @@ -0,0 +1,33 @@ +use crate::{ + app::App, + login::Login, + name::Name, + test::{fixtures, verify}, + token::{app::ValidateError, extract::IdentityCookie}, +}; + +pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) { + let secret = identity + .secret() + .expect("identity cookie must be set to be valid"); + verify::token::valid_for_name(app, &secret, name).await; +} + +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_login(app, &secret, login).await; +} + +pub async fn invalid(app: &App, identity: &IdentityCookie) { + let secret = identity + .secret() + .expect("identity cookie must be set to be invalid"); + let validate_err = app + .tokens() + .validate(&secret, &fixtures::now()) + .await + .expect_err("identity cookie secret must be invalid"); + assert!(matches!(validate_err, ValidateError::InvalidToken)); +} diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs new file mode 100644 index 0000000..ae2e91e --- /dev/null +++ b/src/test/verify/login.rs @@ -0,0 +1,25 @@ +use crate::{ + app::App, + login::app::LoginError, + name::Name, + password::Password, + test::{fixtures, verify}, +}; + +pub async fn valid_login(app: &App, name: &Name, password: &Password) { + let secret = app + .logins() + .with_password(name, password, &fixtures::now()) + .await + .expect("login credentials expected to be valid"); + verify::token::valid_for_name(&app, &secret, &name).await; +} + +pub async fn invalid_login(app: &App, name: &Name, password: &Password) { + let error = app + .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/mod.rs b/src/test/verify/mod.rs new file mode 100644 index 0000000..f809c90 --- /dev/null +++ b/src/test/verify/mod.rs @@ -0,0 +1,3 @@ +pub mod identity; +pub mod login; +pub mod token; diff --git a/src/test/verify/token.rs b/src/test/verify/token.rs new file mode 100644 index 0000000..adc4397 --- /dev/null +++ b/src/test/verify/token.rs @@ -0,0 +1,34 @@ +use crate::{ + app::App, + login::Login, + name::Name, + test::fixtures, + token::{Secret, app}, +}; + +pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) { + let identity = app + .tokens() + .validate(secret, &fixtures::now()) + .await + .expect("provided secret is valid"); + assert_eq!(name, &identity.login.name); +} + +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!(login, &identity.login); +} + +pub async fn invalid(app: &App, secret: &Secret) { + let error = app + .tokens() + .validate(secret, &fixtures::now()) + .await + .expect_err("provided secret is invalid"); + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/token/app.rs b/src/token/app.rs index 56c0e21..fb5d712 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -6,16 +6,11 @@ use futures::{ use sqlx::sqlite::SqlitePool; use super::{ - Broadcaster, Event as TokenEvent, Id, Secret, - repo::{self, Provider as _, auth::Provider as _}, -}; -use crate::{ - clock::DateTime, - db::NotFound as _, - name::{self, Name}, - password::Password, - user::{User, repo::Provider as _}, + Broadcaster, Event as TokenEvent, Secret, Token, + extract::Identity, + repo::{self, Provider as _}, }; +use crate::{clock::DateTime, db::NotFound as _, name}; pub struct Tokens<'a> { db: &'a SqlitePool, @@ -27,106 +22,33 @@ impl<'a> Tokens<'a> { Self { db, token_events } } - pub async fn login( - &self, - name: &Name, - password: &Password, - login_at: &DateTime, - ) -> Result<Secret, LoginError> { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_name(name) - .await - .optional()? - .ok_or(LoginError::Rejected)?; - // Split the transaction here to avoid holding the tx open (potentially blocking - // other writes) while we do the fairly expensive task of verifying the - // password. It's okay if the token issuance transaction happens some notional - // amount of time after retrieving the login, as inserting the token will fail - // if the account is deleted during that time. - tx.commit().await?; - - user.as_snapshot().ok_or(LoginError::Rejected)?; - - if stored_hash.verify(password)? { - let mut tx = self.db.begin().await?; - let secret = tx.tokens().issue(&user, login_at).await?; - tx.commit().await?; - Ok(secret) - } else { - Err(LoginError::Rejected) - } - } - - pub async fn change_password( - &self, - user: &User, - password: &Password, - to: &Password, - changed_at: &DateTime, - ) -> Result<Secret, LoginError> { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_user(user) - .await - .optional()? - .ok_or(LoginError::Rejected)?; - // Split the transaction here to avoid holding the tx open (potentially blocking - // other writes) while we do the fairly expensive task of verifying the - // password. It's okay if the token issuance transaction happens some notional - // amount of time after retrieving the login, as inserting the token will fail - // if the account is deleted during that time. - tx.commit().await?; - - if !stored_hash.verify(password)? { - return Err(LoginError::Rejected); - } - - user.as_snapshot().ok_or(LoginError::Rejected)?; - let to_hash = to.hash()?; - - let mut tx = self.db.begin().await?; - let tokens = tx.tokens().revoke_all(&user).await?; - tx.users().set_password(&user, &to_hash).await?; - let secret = tx.tokens().issue(&user, changed_at).await?; - 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<(Id, User), ValidateError> { + ) -> Result<Identity, ValidateError> { let mut tx = self.db.begin().await?; - let (token, user) = tx + let (token, login) = tx .tokens() .validate(secret, used_at) .await .not_found(|| ValidateError::InvalidToken)?; tx.commit().await?; - let user = user.as_snapshot().ok_or(ValidateError::LoginDeleted)?; - - Ok((token, user)) + Ok(Identity { token, login }) } pub async fn limit_stream<S, E>( &self, - token: Id, + token: &Token, events: S, ) -> Result<impl Stream<Item = E> + std::fmt::Debug + use<S, E>, ValidateError> where S: Stream<Item = E> + std::fmt::Debug, E: std::fmt::Debug, { + let token = token.id.clone(); + // Subscribe, first. let token_events = self.token_events.subscribe(); @@ -187,46 +109,22 @@ impl<'a> Tokens<'a> { Ok(()) } - pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> { + pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> { let mut tx = self.db.begin().await?; tx.tokens().revoke(token).await?; tx.commit().await?; self.token_events - .broadcast(TokenEvent::Revoked(token.clone())); + .broadcast(TokenEvent::Revoked(token.id.clone())); Ok(()) } } #[derive(Debug, thiserror::Error)] -pub enum LoginError { - #[error("invalid login")] - Rejected, - #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] - Name(#[from] name::Error), - #[error(transparent)] - PasswordHash(#[from] password_hash::Error), -} - -impl From<repo::auth::LoadError> for LoginError { - fn from(error: repo::auth::LoadError) -> Self { - use repo::auth::LoadError; - match error { - LoadError::Database(error) => error.into(), - LoadError::Name(error) => error.into(), - } - } -} - -#[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] InvalidToken, - #[error("user deleted")] - LoginDeleted, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index d1c0334..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}, - token::{self, app::ValidateError}, - user::User, + login::Login, + token::{Token, app::ValidateError}, }; #[derive(Clone, Debug)] pub struct Identity { - pub token: token::Id, - pub user: User, + pub token: Token, + pub login: Login, } impl FromRequestParts<App> for Identity { @@ -30,11 +30,13 @@ impl FromRequestParts<App> for Identity { let secret = cookie.secret().ok_or(LoginError::Unauthorized)?; let app = State::<App>::from_request_parts(parts, state).await?; - match app.tokens().validate(&secret, &used_at).await { - Ok((token, user)) => Ok(Identity { token, user }), - Err(ValidateError::InvalidToken) => Err(LoginError::Unauthorized), - Err(other) => Err(other.into()), - } + app.tokens() + .validate(&secret, &used_at) + .await + .map_err(|err| match err { + ValidateError::InvalidToken => LoginError::Unauthorized, + other => other.into(), + }) } } diff --git a/src/token/mod.rs b/src/token/mod.rs index 33403ef..b2dd6f1 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -6,4 +6,32 @@ mod id; pub mod repo; mod secret; +use uuid::Uuid; + 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 login: login::Id, + pub issued_at: DateTime, + pub last_used_at: DateTime, +} + +impl Token { + pub fn generate(login: &Login, issued_at: &DateTime) -> (Self, Secret) { + let id = Id::generate(); + let secret = Uuid::new_v4().to_string().into(); + + ( + Self { + id, + login: login.id.clone(), + issued_at: *issued_at, + last_used_at: *issued_at, + }, + secret, + ) + } +} diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs deleted file mode 100644 index 600855d..0000000 --- a/src/token/repo/auth.rs +++ /dev/null @@ -1,103 +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", - display_name as "display_name: String", - canonical_name as "canonical_name: String", - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime", - password_hash as "password_hash: StoredHash" - from user - where 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_hash)) - } - - pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> { - let row = sqlx::query!( - r#" - select - id as "id: user::Id", - display_name as "display_name: String", - canonical_name as "canonical_name: String", - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime", - password_hash as "password_hash: StoredHash" - from user - 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_hash)) - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum LoadError { - Database(#[from] sqlx::Error), - Name(#[from] name::Error), -} - -impl<T> NotFound for Result<T, LoadError> { - type Ok = T; - type Error = LoadError; - - fn optional(self) -> Result<Option<T>, LoadError> { - match self { - Ok(value) => Ok(Some(value)), - Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), - Err(other) => Err(other), - } - } -} diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs index d8463eb..9df5bbb 100644 --- a/src/token/repo/mod.rs +++ b/src/token/repo/mod.rs @@ -1,4 +1,3 @@ -pub mod auth; mod token; pub use self::token::{LoadError, Provider}; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index 7ac4ac5..52a3987 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -1,13 +1,11 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; -use uuid::Uuid; use crate::{ clock::DateTime, db::NotFound, - event::{Instant, Sequence}, + login::{self, Login}, name::{self, Name}, - token::{Id, Secret}, - user::{self, History, User}, + token::{Id, Secret, Token}, }; pub trait Provider { @@ -23,33 +21,23 @@ impl Provider for Transaction<'_, Sqlite> { pub struct Tokens<'t>(&'t mut SqliteConnection); impl Tokens<'_> { - // Issue a new token for an existing user. The issued_at timestamp will - // determine the token's initial expiry deadline. - pub async fn issue( - &mut self, - user: &History, - issued_at: &DateTime, - ) -> Result<Secret, sqlx::Error> { - let id = Id::generate(); - let secret = Uuid::new_v4().to_string(); - let user = user.id(); - - let secret = sqlx::query_scalar!( + pub async fn create(&mut self, token: &Token, secret: &Secret) -> Result<(), sqlx::Error> { + sqlx::query!( r#" insert - into token (id, secret, user, issued_at, last_used_at) - values ($1, $2, $3, $4, $4) - returning secret as "secret!: Secret" + into token (id, secret, login, issued_at, last_used_at) + values ($1, $2, $3, $4, $5) "#, - id, + token.id, secret, - user, - issued_at, + token.login, + token.issued_at, + token.last_used_at, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; - Ok(secret) + Ok(()) } pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> { @@ -67,34 +55,30 @@ impl Tokens<'_> { Ok(()) } - // Revoke a token by its secret. - pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> { - sqlx::query_scalar!( + pub async fn revoke(&mut self, token: &Token) -> Result<(), sqlx::Error> { + sqlx::query!( r#" - delete - from token + delete from token where id = $1 - returning id as "id: Id" "#, - token, + token.id, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; Ok(()) } // Revoke tokens for a login - pub async fn revoke_all(&mut self, user: &user::History) -> Result<Vec<Id>, sqlx::Error> { - let user = user.id(); + pub async fn revoke_all(&mut self, login: &Login) -> Result<Vec<Id>, sqlx::Error> { let tokens = sqlx::query_scalar!( r#" delete from token - where user = $1 + where login = $1 returning id as "id: Id" "#, - user, + login.id, ) .fetch_all(&mut *self.0) .await?; @@ -120,54 +104,53 @@ 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<(Id, 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 // queries is fine, but it feels untidy. - let (token, user) = sqlx::query!( + let token = sqlx::query!( r#" update token set last_used_at = $1 where secret = $2 returning - id as "token: Id", - user as "user: user::Id" + id as "id: Id", + login as "login: login::Id", + issued_at as "issued_at: DateTime", + last_used_at as "last_used_at: DateTime" "#, used_at, secret, ) - .map(|row| (row.token, row.user)) + .map(|row| Token { + id: row.id, + login: row.login, + issued_at: row.issued_at, + last_used_at: row.last_used_at, + }) .fetch_one(&mut *self.0) .await?; let user = sqlx::query!( r#" select - id as "id: user::Id", - display_name as "display_name: String", - canonical_name as "canonical_name: String", - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" - from user + id as "id: login::Id", + display_name, + canonical_name + from login where id = $1 "#, - user, + token.login, ) .map(|row| { - Ok::<_, name::Error>(History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), + Ok::<_, name::Error>(Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, }) }) .fetch_one(&mut *self.0) diff --git a/src/user/app.rs b/src/user/app.rs index 301c39c..0d6046c 100644 --- a/src/user/app.rs +++ b/src/user/app.rs @@ -1,10 +1,7 @@ use sqlx::sqlite::SqlitePool; -use super::{ - User, - create::{self, Create}, -}; -use crate::{clock::DateTime, event::Broadcaster, name::Name, password::Password}; +use super::create::{self, Create}; +use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password}; pub struct Users<'a> { db: &'a SqlitePool, @@ -21,7 +18,7 @@ impl<'a> Users<'a> { name: &Name, password: &Password, created_at: &DateTime, - ) -> Result<User, CreateError> { + ) -> Result<Login, CreateError> { let create = Create::begin(name, password, created_at); let validated = create.validate()?; @@ -29,10 +26,10 @@ impl<'a> Users<'a> { let stored = validated.store(&mut tx).await?; tx.commit().await?; - let user = stored.user().to_owned(); + let login = stored.login().to_owned(); stored.publish(self.events); - Ok(user.as_created()) + Ok(login) } } @@ -46,7 +43,6 @@ pub enum CreateError { Database(#[from] sqlx::Error), } -#[cfg(test)] impl From<create::Error> for CreateError { fn from(error: create::Error) -> Self { match error { diff --git a/src/user/create.rs b/src/user/create.rs index 5d7bf65..5c060c9 100644 --- a/src/user/create.rs +++ b/src/user/create.rs @@ -4,6 +4,7 @@ use super::{History, repo::Provider as _, validate}; use crate::{ clock::DateTime, event::{Broadcaster, Event, repo::Provider as _}, + login::{self, Login, repo::Provider as _}, name::Name, password::{Password, StoredHash}, }; @@ -39,7 +40,7 @@ impl<'a> Create<'a> { Ok(Validated { name, - password_hash, + password: password_hash, created_at, }) } @@ -48,7 +49,7 @@ impl<'a> Create<'a> { #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Validated<'a> { name: &'a Name, - password_hash: StoredHash, + password: StoredHash, created_at: &'a DateTime, } @@ -56,31 +57,38 @@ impl Validated<'_> { pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result<Stored, sqlx::Error> { let Self { name, - password_hash, + password, created_at, } = self; + let login = Login { + id: login::Id::generate(), + name: name.to_owned(), + }; + let created = tx.sequence().next(created_at).await?; - let user = tx.users().create(name, &password_hash, &created).await?; + tx.logins().create(&login, &password).await?; + let user = tx.users().create(&login, &created).await?; - Ok(Stored { user }) + Ok(Stored { user, login }) } } #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Stored { user: History, + login: Login, } impl Stored { - pub fn publish(self, events: &Broadcaster) { - let Self { user } = self; + pub fn publish(self, broadcaster: &Broadcaster) { + let Self { user, login: _ } = self; - events.broadcast(user.events().map(Event::from).collect::<Vec<_>>()); + broadcaster.broadcast(user.events().map(Event::from).collect::<Vec<_>>()); } - pub fn user(&self) -> &History { - &self.user + pub fn login(&self) -> &Login { + &self.login } } diff --git a/src/user/handlers/password/test.rs b/src/user/handlers/password/test.rs deleted file mode 100644 index ffa12f3..0000000 --- a/src/user/handlers/password/test.rs +++ /dev/null @@ -1,81 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::fixtures, - token::app::{LoginError, ValidateError}, -}; - -#[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 - let new_secret = new_cookie - .secret() - .expect("we should have a secret after changing our password"); - let (_, login) = app - .tokens() - .validate(&new_secret, &fixtures::now()) - .await - .expect("the newly-issued secret should be valid"); - assert_eq!(identity.user, login); - - // Verify that our original token is no longer valid - let validate_err = app - .tokens() - .validate( - &cookie - .secret() - .expect("original identity cookie has a secret"), - &fixtures::now(), - ) - .await - .expect_err("validating the original identity secret should fail"); - assert!(matches!(validate_err, ValidateError::InvalidToken)); - - // Verify that our original password is no longer valid - let login_err = app - .tokens() - .login(&name, &password, &fixtures::now()) - .await - .expect_err("logging in with the original password should fail"); - assert!(matches!(login_err, LoginError::Rejected)); - - // Verify that our new password is valid - let secret = app - .tokens() - .login(&name, &to, &fixtures::now()) - .await - .expect("logging in with the new password should succeed"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("validating a newly-issued token secret succeeds"); - assert_eq!(identity.user, login); -} diff --git a/src/user/history.rs b/src/user/history.rs index 4f99130..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,22 +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.") - #[cfg(test)] - pub fn as_created(&self) -> User { - self.user.clone() - } - - // Snapshot of this user, as of all events recorded in this history. - pub fn as_snapshot(&self) -> Option<User> { - self.events().collect() + pub fn as_of<S>(&self, sequence: S) -> Option<User> + where + S: Into<Sequence>, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() } } diff --git a/src/user/id.rs b/src/user/id.rs index 3ad8d16..ceb310a 100644 --- a/src/user/id.rs +++ b/src/user/id.rs @@ -1,7 +1,24 @@ +use crate::login; + // Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L` // prefix, instead. pub type Id = crate::id::Id<User>; +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From<login::Id> for Id { + fn from(login: login::Id) -> Self { + Self::from(String::from(login)) + } +} + +impl PartialEq<login::Id> for Id { + fn eq(&self, other: &login::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct User; diff --git a/src/user/mod.rs b/src/user/mod.rs index 60ec209..95bec2f 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -2,9 +2,8 @@ pub mod app; pub mod create; pub mod event; -pub mod handlers; mod history; -mod id; +pub mod id; pub mod repo; mod snapshot; mod validate; diff --git a/src/user/repo.rs b/src/user/repo.rs index 0f67e9a..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,62 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection); impl Users<'_> { pub async fn create( &mut self, - name: &Name, - password_hash: &StoredHash, + login: &Login, created: &Instant, ) -> Result<History, sqlx::Error> { - let id = Id::generate(); - let display_name = name.display(); - let canonical_name = name.canonical(); - sqlx::query!( r#" - insert - into user (id, display_name, canonical_name, password_hash, created_sequence, created_at) - values ($1, $2, $3, $4, $5, $6) + insert into user (id, created_sequence, created_at) + values ($1, $2, $3) "#, - id, - display_name, - canonical_name, - password_hash, + login.id, created.sequence, created.at, ) .execute(&mut *self.0) .await?; - let user = History { - created: *created, + Ok(History { user: User { - id, - name: name.clone(), + id: login.id.clone().into(), + name: login.name.clone(), }, - }; - - Ok(user) + created: *created, + }) } - pub async fn set_password( - &mut self, - login: &History, - to: &StoredHash, - ) -> Result<(), sqlx::Error> { - let login = login.id(); - - sqlx::query_scalar!( + pub async fn by_login(&mut self, login: &Login) -> Result<History, LoadError> { + let user = sqlx::query!( r#" - update user - set password_hash = $1 - where id = $2 - returning id as "id: Id" + select + id as "id: Id", + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_at as "created_at: DateTime", + user.created_sequence as "created_sequence: Sequence" + from user + join login using (id) + where id = $1 "#, - to, - login, + login.id, ) + .map(|row| { + Ok::<_, LoadError>(History { + user: User { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) + }) .fetch_one(&mut *self.0) - .await?; + .await??; - Ok(()) + Ok(user) } pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> { @@ -87,13 +84,14 @@ impl Users<'_> { r#" select id as "id: Id", - display_name as "display_name: String", - canonical_name as "canonical_name: String", - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" + 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 - where created_sequence <= $1 - order by canonical_name + join login using (id) + where user.created_sequence <= $1 + order by canonical_name "#, resume_at, ) @@ -119,12 +117,13 @@ impl Users<'_> { r#" select id as "id: Id", - display_name as "display_name: String", - canonical_name as "canonical_name: String", - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" + 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 - where created_sequence > $1 + join login using (id) + where user.created_sequence > $1 "#, resume_at, ) @@ -152,3 +151,16 @@ pub enum LoadError { Database(#[from] sqlx::Error), Name(#[from] name::Error), } + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index 42b86f0..c415d0c 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -62,9 +62,9 @@ class Session { ), ); - static boot({ user, resume_point, heartbeat, events }) { + static boot({ login, resume_point, heartbeat, events }) { const remote = r.State.boot({ - currentUser: user, + currentUser: login, resumePoint: resume_point, heartbeat, events, @@ -73,9 +73,9 @@ class Session { return new Session(remote, local); } - reboot({ user, resume_point, heartbeat, events }) { + reboot({ login, resume_point, heartbeat, events }) { this.remote = r.State.boot({ - currentUser: user, + currentUser: login, resumePoint: resume_point, heartbeat, events, |
