From 6c65e97e49d1d56380aa7d71abb0394b08ff60ca Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 24 Aug 2025 16:00:45 -0400 Subject: Return an identity, rather than the parts of an identity, when validating an identity token. This is a small refactoring that's been possible for a while, and we only just noticed. --- src/user/handlers/login/test.rs | 7 +++---- src/user/handlers/logout/mod.rs | 4 ++-- src/user/handlers/password/test.rs | 8 ++++---- 3 files changed, 9 insertions(+), 10 deletions(-) (limited to 'src/user') diff --git a/src/user/handlers/login/test.rs b/src/user/handlers/login/test.rs index bdd1957..cb387ad 100644 --- a/src/user/handlers/login/test.rs +++ b/src/user/handlers/login/test.rs @@ -30,14 +30,13 @@ async fn correct_credentials() { // Verify the semantics - let validated_at = fixtures::now(); - let (_, validated_login) = app + let validated = app .tokens() - .validate(&secret, &validated_at) + .validate(&secret, &fixtures::now()) .await .expect("identity secret is valid"); - assert_eq!(name, validated_login.name); + assert_eq!(name, validated.user.name); } #[tokio::test] diff --git a/src/user/handlers/logout/mod.rs b/src/user/handlers/logout/mod.rs index 4450e4c..f759451 100644 --- a/src/user/handlers/logout/mod.rs +++ b/src/user/handlers/logout/mod.rs @@ -21,8 +21,8 @@ pub async fn handler( Json(_): Json, ) -> Result<(IdentityCookie, Empty), Error> { if let Some(secret) = identity.secret() { - let (token, _) = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&token).await?; + let validated_ident = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&validated_ident.token).await?; } let identity = identity.clear(); diff --git a/src/user/handlers/password/test.rs b/src/user/handlers/password/test.rs index ffa12f3..c0f789b 100644 --- a/src/user/handlers/password/test.rs +++ b/src/user/handlers/password/test.rs @@ -38,12 +38,12 @@ async fn password_change() { let new_secret = new_cookie .secret() .expect("we should have a secret after changing our password"); - let (_, login) = app + let new_identity = app .tokens() .validate(&new_secret, &fixtures::now()) .await .expect("the newly-issued secret should be valid"); - assert_eq!(identity.user, login); + assert_eq!(identity.user, new_identity.user); // Verify that our original token is no longer valid let validate_err = app @@ -72,10 +72,10 @@ async fn password_change() { .login(&name, &to, &fixtures::now()) .await .expect("logging in with the new password should succeed"); - let (_, login) = app + let identity = app .tokens() .validate(&secret, &fixtures::now()) .await .expect("validating a newly-issued token secret succeeds"); - assert_eq!(identity.user, login); + assert_eq!(name, identity.user.name); } -- cgit v1.2.3 From c52e24f17ed615b2e2dd55a285eb272014a2ccbf Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 24 Aug 2025 16:37:41 -0400 Subject: Factor out common authentication test verification steps into helpers. These checks tended to be wordy, and were prone to being done subtly differently in different locations for no good reason. Centralizing them cleans this up and makes the tests easier to follow, at the expense of making it somewhat harder to follow what the test is specifically checking. --- src/invite/handlers/accept/test.rs | 24 +++++++-------------- src/setup/handlers/setup/test.rs | 30 ++++++-------------------- src/test/mod.rs | 1 + src/test/verify/identity.rs | 33 ++++++++++++++++++++++++++++ src/test/verify/login.rs | 25 ++++++++++++++++++++++ src/test/verify/mod.rs | 3 +++ src/test/verify/token.rs | 34 +++++++++++++++++++++++++++++ src/user/handlers/login/test.rs | 29 ++++++------------------- src/user/handlers/logout/test.rs | 14 ++++++------ src/user/handlers/password/test.rs | 44 +++++--------------------------------- 10 files changed, 129 insertions(+), 108 deletions(-) create mode 100644 src/test/verify/identity.rs create mode 100644 src/test/verify/login.rs create mode 100644 src/test/verify/mod.rs create mode 100644 src/test/verify/token.rs (limited to 'src/user') diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs index 5140e3f..4e4a09d 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,15 +39,7 @@ async fn valid_invite() { // Verify that the issued token is valid - let secret = identity - .secret() - .expect("newly-issued identity has a token secret"); - let identity = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("newly-issued identity cookie is valid"); - assert_eq!(name, identity.user.name); + verify::identity::valid_for_name(&app, &identity, &name).await; // Verify that the given credentials can log in @@ -51,12 +48,7 @@ async fn valid_invite() { .login(&name, &password, &fixtures::now()) .await .expect("credentials given on signup are valid"); - let identity = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("validating a newly-issued token secret succeeds"); - assert_eq!(name, identity.user.name); + verify::token::valid_for_name(&app, &secret, &name).await; } #[tokio::test] diff --git a/src/setup/handlers/setup/test.rs b/src/setup/handlers/setup/test.rs index 133a162..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 identity = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("newly-issued identity cookie is valid"); - assert_eq!(name, identity.user.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 identity = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("validating a newly-issued token secret succeeds"); - assert_eq!(name, identity.user.name); + verify::login::valid_login(&app, &name, &password).await; } #[tokio::test] 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..226ee74 --- /dev/null +++ b/src/test/verify/identity.rs @@ -0,0 +1,33 @@ +use crate::{ + app::App, + name::Name, + test::{fixtures, verify}, + token::{app::ValidateError, extract::IdentityCookie}, + user::User, +}; + +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_user(app: &App, identity: &IdentityCookie, user: &User) { + let secret = identity + .secret() + .expect("identity cookie must be set to be valid"); + verify::token::valid_for_user(app, &secret, user).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..3f291a3 --- /dev/null +++ b/src/test/verify/login.rs @@ -0,0 +1,25 @@ +use crate::{ + app::App, + name::Name, + password::Password, + test::{fixtures, verify}, + token::app::LoginError, +}; + +pub async fn valid_login(app: &App, name: &Name, password: &Password) { + let secret = app + .tokens() + .login(&name, &password, &fixtures::now()) + .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 + .tokens() + .login(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..99cd31f --- /dev/null +++ b/src/test/verify/token.rs @@ -0,0 +1,34 @@ +use crate::{ + app::App, + name::Name, + test::fixtures, + token::{Secret, app}, + user::User, +}; + +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.user.name); +} + +pub async fn valid_for_user(app: &App, secret: &Secret, user: &User) { + let identity = app + .tokens() + .validate(secret, &fixtures::now()) + .await + .expect("provided secret is valid"); + assert_eq!(user, &identity.user); +} + +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/user/handlers/login/test.rs b/src/user/handlers/login/test.rs index cb387ad..56fc2c4 100644 --- a/src/user/handlers/login/test.rs +++ b/src/user/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, + test::{fixtures, verify}, + token::app, +}; #[tokio::test] async fn correct_credentials() { @@ -24,19 +28,7 @@ async fn correct_credentials() { // Verify the return value's basic structure - let secret = identity - .secret() - .expect("logged in with valid credentials issues an identity cookie"); - - // Verify the semantics - - let validated = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("identity secret is valid"); - - assert_eq!(name, validated.user.name); + verify::identity::valid_for_name(&app, &identity, &name).await; } #[tokio::test] @@ -114,12 +106,5 @@ async fn token_expires() { .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/test.rs b/src/user/handlers/logout/test.rs index 7151ddf..8ad4853 100644 --- a/src/user/handlers/logout/test.rs +++ b/src/user/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() { @@ -24,16 +28,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::token::invalid(&app, &secret).await; } #[tokio::test] diff --git a/src/user/handlers/password/test.rs b/src/user/handlers/password/test.rs index c0f789b..81020a1 100644 --- a/src/user/handlers/password/test.rs +++ b/src/user/handlers/password/test.rs @@ -2,8 +2,7 @@ use axum::extract::{Json, State}; use crate::{ empty::Empty, - test::fixtures, - token::app::{LoginError, ValidateError}, + test::{fixtures, verify}, }; #[tokio::test] @@ -35,47 +34,14 @@ async fn password_change() { 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 new_identity = app - .tokens() - .validate(&new_secret, &fixtures::now()) - .await - .expect("the newly-issued secret should be valid"); - assert_eq!(identity.user, new_identity.user); + verify::identity::valid_for_user(&app, &new_cookie, &identity.user).await; // 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::identity::invalid(&app, &cookie).await; // 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::login::invalid_login(&app, &name, &password).await; // 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 identity = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("validating a newly-issued token secret succeeds"); - assert_eq!(name, identity.user.name); + verify::login::valid_login(&app, &name, &to).await; } -- cgit v1.2.3 From 218d6dbb56727721d19019c8514f5e4395596e98 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 24 Aug 2025 15:35:39 -0400 Subject: Split the `user` table into an authentication portion and a chat portion. We'll be building separate entities around this in future commits, to better separate the authentication data (non-synchronized and indeed "not public") from the chat data (synchronized and public). --- ...fd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json | 20 +++ ...578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json | 50 ++++++ ...776b5d0e847cf52c68f24f5ea878b897944e70254c.json | 20 --- ...22966d85ded0cbd27125093208d7f7f0aabb0ea61b.json | 50 ++++++ ...713f6354d6150088a052d1f261853f327a83e8dd75.json | 20 --- ...4f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json | 44 +++++ ...26a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json | 38 ---- ...213750fe202abfa2af5347010dee59d3b8075eb19e.json | 20 --- ...8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json | 26 --- ...66b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json | 50 ------ ...4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json | 12 ++ ...36d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json | 26 +++ ...4db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json | 20 +++ ...7a6bbb9dfe26bce0812a215573a276043095cd872c.json | 44 +++++ ...0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json | 38 ++++ ...af911f7488a708d5a50920bb042c0229c314ee3281.json | 44 ----- ...d84b6ac46f63d2804ef3528d833e6d17bec8864454.json | 44 ----- ...b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json | 44 ----- ...7cf0360789d46d94d9cd98887a3d9660d9b753d416.json | 50 ------ ...2b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json | 44 +++++ ...c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json | 20 +++ ...0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json | 12 ++ ...29f3a52be8d3134fc68e37c6074c69970604ae3844.json | 12 -- migrations/20250822213537_split_user.sql | 195 +++++++++++++++++++++ src/invite/repo.rs | 3 +- src/token/repo/auth.rs | 28 +-- src/token/repo/token.rs | 17 +- src/user/repo.rs | 47 +++-- 28 files changed, 630 insertions(+), 408 deletions(-) create mode 100644 .sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json create mode 100644 .sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json delete mode 100644 .sqlx/query-0be9484c8d277b08a925b7776b5d0e847cf52c68f24f5ea878b897944e70254c.json create mode 100644 .sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json delete mode 100644 .sqlx/query-27c3ffb5c284cf2d1c7cee713f6354d6150088a052d1f261853f327a83e8dd75.json create mode 100644 .sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json delete mode 100644 .sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json delete mode 100644 .sqlx/query-613199c25e16a52d85761e213750fe202abfa2af5347010dee59d3b8075eb19e.json delete mode 100644 .sqlx/query-684b4bef3e553b8ccfa20d8de3272e44b1c15ed75977ce24f25fd62042fb20fb.json delete mode 100644 .sqlx/query-79495da0101ad12e7e983666b2ca84cbe31458ff73a94648c30b62f3130a5a8b.json create mode 100644 .sqlx/query-7c87864e6d077640e4f3ef4a354a47265238010f4c53d3c5982f4fd95ed1f6d0.json create mode 100644 .sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json create mode 100644 .sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json create mode 100644 .sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json create mode 100644 .sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json delete mode 100644 .sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json delete mode 100644 .sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json delete mode 100644 .sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json delete mode 100644 .sqlx/query-afc6db503a3c49c18a9cb07cf0360789d46d94d9cd98887a3d9660d9b753d416.json create mode 100644 .sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json create mode 100644 .sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json create mode 100644 .sqlx/query-f9a6a39c45c3b039f139da0475c25112ffb2b26584ada60c324fc2f945c3d2fa.json delete mode 100644 .sqlx/query-f9abb172f96bff3a5fb4ad29f3a52be8d3134fc68e37c6074c69970604ae3844.json create mode 100644 migrations/20250822213537_split_user.sql (limited to 'src/user') diff --git a/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json new file mode 100644 index 0000000..937b07e --- /dev/null +++ b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n delete\n from token\n where login = $1\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d" +} diff --git a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json b/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json new file mode 100644 index 0000000..4bc5119 --- /dev/null +++ b/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: user::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "password: StoredHash", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c" +} diff --git a/.sqlx/query-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-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json b/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json new file mode 100644 index 0000000..f4ae749 --- /dev/null +++ b/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where login.canonical_name = $1\n ", + "describe": { + "columns": [ + { + "name": "id: user::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "password: StoredHash", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b" +} diff --git a/.sqlx/query-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-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json b/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json new file mode 100644 index 0000000..1a4389f --- /dev/null +++ b/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: user::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509" +} diff --git a/.sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json b/.sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json deleted file mode 100644 index 31c14de..0000000 --- a/.sqlx/query-53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "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 ", - "describe": { - "columns": [ - { - "name": "invite_id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "issuer_id: user::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "issuer_name: nfc::String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "invite_issued_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "53193435a6eeb72266d0e526a3c6adfcfa20b687ac886efe6d41d5abfd7bc183" -} 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-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json b/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json new file mode 100644 index 0000000..1efb9a7 --- /dev/null +++ b/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n login as \"login: user::Id\"\n ", + "describe": { + "columns": [ + { + "name": "token: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login: user::Id", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234" +} diff --git a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json b/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json new file mode 100644 index 0000000..b433e4c --- /dev/null +++ b/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $4)\n returning secret as \"secret!: Secret\"\n ", + "describe": { + "columns": [ + { + "name": "secret!: Secret", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false + ] + }, + "hash": "8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769" +} diff --git a/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json b/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json new file mode 100644 index 0000000..cbe1cdf --- /dev/null +++ b/.sqlx/query-8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_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": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "8dae7dbe085898659013167a6bbb9dfe26bce0812a215573a276043095cd872c" +} diff --git a/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json b/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json new file mode 100644 index 0000000..1025308 --- /dev/null +++ b/.sqlx/query-963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "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": [ + { + "name": "invite_id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "issuer_id: user::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issuer_name: nfc::String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "invite_issued_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "963e21b97f4932da6632ab0fd1eb959e6e8ff1719c0a0111a868b7bd60499477" +} diff --git a/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json b/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json deleted file mode 100644 index 0926b67..0000000 --- a/.sqlx/query-9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281.json +++ /dev/null @@ -1,44 +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 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" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "9cf211b1f37708fc8f19b3af911f7488a708d5a50920bb042c0229c314ee3281" -} diff --git a/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json b/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json deleted file mode 100644 index beacb24..0000000 --- a/.sqlx/query-9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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 ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "9e4e8544c86e36901b4543d84b6ac46f63d2804ef3528d833e6d17bec8864454" -} diff --git a/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json b/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json deleted file mode 100644 index 45177f3..0000000 --- a/.sqlx/query-9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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 ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "9eb1e7d793fe6a992dc937b041e5b5206628c3f0cd5230f6671bf8d8946d01f2" -} 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-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json b/.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json new file mode 100644 index 0000000..e56faa9 --- /dev/null +++ b/.sqlx/query-be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_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: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "be644101e1fd50880fa7c82b07cc6e9f69c64bb790d6c52ad84872f256c749aa" +} diff --git a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json b/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json new file mode 100644 index 0000000..200c357 --- /dev/null +++ b/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n update login\n set password = $1\n where id = $2\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c" +} diff --git a/.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/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); 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, diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs index 600855d..a42fa1a 100644 --- a/src/token/repo/auth.rs +++ b/src/token/repo/auth.rs @@ -28,13 +28,14 @@ impl Auth<'_> { 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" + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_sequence as "created_sequence: Sequence", + user.created_at as "created_at: DateTime", + login.password as "password: StoredHash" from user - where canonical_name = $1 + join login using (id) + where login.canonical_name = $1 "#, name, ) @@ -49,7 +50,7 @@ impl Auth<'_> { created: Instant::new(row.created_at, row.created_sequence), }; - Ok((login, row.password_hash)) + Ok((login, row.password)) } pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> { @@ -57,12 +58,13 @@ impl Auth<'_> { 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" + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_sequence as "created_sequence: Sequence", + user.created_at as "created_at: DateTime", + login.password as "password: StoredHash" from user + join login using (id) where id = $1 "#, user.id, @@ -78,7 +80,7 @@ impl Auth<'_> { created: Instant::new(row.created_at, row.created_sequence), }; - Ok((user, row.password_hash)) + Ok((user, row.password)) } } diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index 7ac4ac5..5368fee 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -37,7 +37,7 @@ impl Tokens<'_> { let secret = sqlx::query_scalar!( r#" insert - into token (id, secret, user, issued_at, last_used_at) + into token (id, secret, login, issued_at, last_used_at) values ($1, $2, $3, $4, $4) returning secret as "secret!: Secret" "#, @@ -91,7 +91,7 @@ impl Tokens<'_> { r#" delete from token - where user = $1 + where login = $1 returning id as "id: Id" "#, user, @@ -139,12 +139,12 @@ impl Tokens<'_> { where secret = $2 returning id as "token: Id", - user as "user: user::Id" + login as "login: user::Id" "#, used_at, secret, ) - .map(|row| (row.token, row.user)) + .map(|row| (row.token, row.login)) .fetch_one(&mut *self.0) .await?; @@ -152,11 +152,12 @@ impl Tokens<'_> { 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" + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_sequence as "created_sequence: Sequence", + user.created_at as "created_at: DateTime" from user + join login using (id) where id = $1 "#, user, diff --git a/src/user/repo.rs b/src/user/repo.rs index 0f67e9a..bfb603d 100644 --- a/src/user/repo.rs +++ b/src/user/repo.rs @@ -25,7 +25,7 @@ impl Users<'_> { pub async fn create( &mut self, name: &Name, - password_hash: &StoredHash, + password: &StoredHash, created: &Instant, ) -> Result { let id = Id::generate(); @@ -34,14 +34,23 @@ impl Users<'_> { 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 login (id, display_name, canonical_name, password) + values ($1, $2, $3, $4) "#, id, display_name, canonical_name, - password_hash, + password, + ) + .execute(&mut *self.0) + .await?; + + sqlx::query!( + r#" + insert into user (id, created_sequence, created_at) + values ($1, $2, $3) + "#, + id, created.sequence, created.at, ) @@ -68,8 +77,8 @@ impl Users<'_> { sqlx::query_scalar!( r#" - update user - set password_hash = $1 + update login + set password = $1 where id = $2 returning id as "id: Id" "#, @@ -87,13 +96,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 +129,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, ) -- cgit v1.2.3 From 0bbc83f09cc7517dddf16770a15f9e90815f48ba Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 24 Aug 2025 17:03:16 -0400 Subject: Generate tokens in memory and then store them. This is the leading edge of a larger storage refactoring, where repo types stop doing things like generating secrets or deciding whether to carry out an operation. To make this work, there is now a `Token` type that holds the complete state of a token, in memory. --- ...3bb514b7738aabfa740a19923d4c815bee860de1e8.json | 12 +++++ ...6678c9709a8637630faa759d404ebd4eb2545dab72.json | 38 ++++++++++++++ ...57469f1bff84ac343dc551c64e82e114b780e956a4.json | 12 +++++ ...36d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json | 26 --------- ...4db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json | 20 ------- ...e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json | 20 ------- src/event/handlers/stream/mod.rs | 2 +- src/invite/app.rs | 6 ++- src/setup/app.rs | 21 ++++---- src/test/fixtures/identity.rs | 4 +- src/token/app.rs | 25 +++++---- src/token/extract/identity.rs | 4 +- src/token/mod.rs | 32 ++++++++++++ src/token/repo/token.rs | 61 ++++++++++------------ src/user/history.rs | 1 - 15 files changed, 158 insertions(+), 126 deletions(-) create mode 100644 .sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json create mode 100644 .sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json create mode 100644 .sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json delete mode 100644 .sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json delete mode 100644 .sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json delete mode 100644 .sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json (limited to 'src/user') diff --git a/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json new file mode 100644 index 0000000..37a6dd3 --- /dev/null +++ b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8" +} diff --git a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json new file mode 100644 index 0000000..99a05e3 --- /dev/null +++ b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: user::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login: user::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issued_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_used_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72" +} diff --git a/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json new file mode 100644 index 0000000..c9edfc9 --- /dev/null +++ b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from token\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4" +} diff --git a/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json b/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json deleted file mode 100644 index 1efb9a7..0000000 --- a/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n login as \"login: user::Id\"\n ", - "describe": { - "columns": [ - { - "name": "token: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "login: user::Id", - "ordinal": 1, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false - ] - }, - "hash": "7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234" -} diff --git a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json b/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json deleted file mode 100644 index b433e4c..0000000 --- a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $4)\n returning secret as \"secret!: Secret\"\n ", - "describe": { - "columns": [ - { - "name": "secret!: Secret", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false - ] - }, - "hash": "8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769" -} diff --git a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json b/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json deleted file mode 100644 index 1be8e07..0000000 --- a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n delete\n from token\n where id = $1\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f" -} diff --git a/src/event/handlers/stream/mod.rs b/src/event/handlers/stream/mod.rs index d0d3f08..63bfff3 100644 --- a/src/event/handlers/stream/mod.rs +++ b/src/event/handlers/stream/mod.rs @@ -27,7 +27,7 @@ pub async fn handler( let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner); let stream = app.events().subscribe(resume_at).await?; - let stream = app.tokens().limit_stream(identity.token, stream).await?; + let stream = app.tokens().limit_stream(&identity.token, stream).await?; Ok(Response(stream)) } diff --git a/src/invite/app.rs b/src/invite/app.rs index 1c85562..6e235b2 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -8,7 +8,7 @@ use crate::{ event::Broadcaster, name::Name, password::Password, - token::{Secret, repo::Provider as _}, + token::{Secret, Token, repo::Provider as _}, user::{ User, create::{self, Create}, @@ -71,7 +71,9 @@ impl<'a> Invites<'a> { .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(stored.user(), accepted_at).await?; + let user = stored.user().as_created(); + let (token, secret) = Token::generate(&user, accepted_at); + tx.tokens().create(&token, &secret).await?; tx.commit().await?; stored.publish(self.events); diff --git a/src/setup/app.rs b/src/setup/app.rs index 1856519..c1c53c5 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -6,7 +6,7 @@ use crate::{ event::Broadcaster, name::Name, password::Password, - token::{Secret, repo::Provider as _}, + token::{Secret, Token, repo::Provider as _}, user::create::{self, Create}, }; @@ -31,17 +31,20 @@ impl<'a> Setup<'a> { let validated = create.validate()?; let mut tx = self.db.begin().await?; - let stored = if tx.setup().completed().await? { - Err(Error::SetupCompleted)? + if tx.setup().completed().await? { + Err(Error::SetupCompleted) } else { - validated.store(&mut tx).await? - }; - let secret = tx.tokens().issue(stored.user(), created_at).await?; - tx.commit().await?; + let stored = validated.store(&mut tx).await?; + let user = stored.user().as_created(); + + let (token, secret) = Token::generate(&user, created_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; - stored.publish(self.events); + stored.publish(self.events); - Ok(secret) + Ok(secret) + } } pub async fn completed(&self) -> Result { diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index 29ff5ae..ac353de 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -5,7 +5,7 @@ use crate::{ password::Password, test::fixtures, token::{ - self, + Token, extract::{Identity, IdentityCookie}, }, }; @@ -37,8 +37,8 @@ pub async fn logged_in( } pub fn fictitious() -> Identity { - let token = token::Id::generate(); let user = fixtures::user::fictitious(); + let (token, _) = Token::generate(&user, &fixtures::now()); Identity { token, user } } diff --git a/src/token/app.rs b/src/token/app.rs index 8ec61c5..a7a843d 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -6,7 +6,7 @@ use futures::{ use sqlx::sqlite::SqlitePool; use super::{ - Broadcaster, Event as TokenEvent, Id, Secret, + Broadcaster, Event as TokenEvent, Secret, Token, extract::Identity, repo::{self, Provider as _, auth::Provider as _}, }; @@ -48,12 +48,14 @@ impl<'a> Tokens<'a> { // if the account is deleted during that time. tx.commit().await?; - user.as_snapshot().ok_or(LoginError::Rejected)?; + let user = user.as_snapshot().ok_or(LoginError::Rejected)?; if stored_hash.verify(password)? { let mut tx = self.db.begin().await?; - let secret = tx.tokens().issue(&user, login_at).await?; + let (token, secret) = Token::generate(&user, login_at); + tx.tokens().create(&token, &secret).await?; tx.commit().await?; + Ok(secret) } else { Err(LoginError::Rejected) @@ -85,13 +87,16 @@ impl<'a> Tokens<'a> { return Err(LoginError::Rejected); } - user.as_snapshot().ok_or(LoginError::Rejected)?; + let user_snapshot = user.as_snapshot().ok_or(LoginError::Rejected)?; let to_hash = to.hash()?; let mut tx = self.db.begin().await?; - let tokens = tx.tokens().revoke_all(&user).await?; tx.users().set_password(&user, &to_hash).await?; - let secret = tx.tokens().issue(&user, changed_at).await?; + + let tokens = tx.tokens().revoke_all(&user).await?; + let (token, secret) = Token::generate(&user_snapshot, changed_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; for event in tokens.into_iter().map(TokenEvent::Revoked) { @@ -121,13 +126,15 @@ impl<'a> Tokens<'a> { pub async fn limit_stream( &self, - token: Id, + token: &Token, events: S, ) -> Result + std::fmt::Debug + use, ValidateError> where S: Stream + std::fmt::Debug, E: std::fmt::Debug, { + let token = token.id.clone(); + // Subscribe, first. let token_events = self.token_events.subscribe(); @@ -188,13 +195,13 @@ impl<'a> Tokens<'a> { Ok(()) } - pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> { + pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> { let mut tx = self.db.begin().await?; tx.tokens().revoke(token).await?; tx.commit().await?; self.token_events - .broadcast(TokenEvent::Revoked(token.clone())); + .broadcast(TokenEvent::Revoked(token.id.clone())); Ok(()) } diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index 4d076d7..d01ab53 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -10,13 +10,13 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, Unauthorized}, - token::{self, app::ValidateError}, + token::{Token, app::ValidateError}, user::User, }; #[derive(Clone, Debug)] pub struct Identity { - pub token: token::Id, + pub token: Token, pub user: User, } diff --git a/src/token/mod.rs b/src/token/mod.rs index 33403ef..58ff08b 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -6,4 +6,36 @@ mod id; pub mod repo; mod secret; +use uuid::Uuid; + +use crate::{ + clock::DateTime, + user::{self, User}, +}; + pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret}; + +#[derive(Clone, Debug)] +pub struct Token { + pub id: Id, + pub user: user::Id, + pub issued_at: DateTime, + pub last_used_at: DateTime, +} + +impl Token { + pub fn generate(user: &User, issued_at: &DateTime) -> (Self, Secret) { + let id = Id::generate(); + let secret = Uuid::new_v4().to_string().into(); + + ( + Self { + id, + user: user.id.clone(), + issued_at: *issued_at, + last_used_at: *issued_at, + }, + secret, + ) + } +} diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index 5368fee..afcde53 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -1,12 +1,11 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; -use uuid::Uuid; use crate::{ clock::DateTime, db::NotFound, event::{Instant, Sequence}, name::{self, Name}, - token::{Id, Secret}, + token::{Id, Secret, Token}, user::{self, History, User}, }; @@ -23,33 +22,23 @@ impl Provider for Transaction<'_, Sqlite> { pub struct Tokens<'t>(&'t mut SqliteConnection); impl Tokens<'_> { - // Issue a new token for an existing user. The issued_at timestamp will - // determine the token's initial expiry deadline. - pub async fn issue( - &mut self, - user: &History, - issued_at: &DateTime, - ) -> Result { - let id = Id::generate(); - let secret = Uuid::new_v4().to_string(); - let user = user.id(); - - let secret = sqlx::query_scalar!( + pub async fn create(&mut self, token: &Token, secret: &Secret) -> Result<(), sqlx::Error> { + sqlx::query!( r#" insert into token (id, secret, login, issued_at, last_used_at) - values ($1, $2, $3, $4, $4) - returning secret as "secret!: Secret" + values ($1, $2, $3, $4, $5) "#, - id, + token.id, secret, - user, - issued_at, + token.user, + token.issued_at, + token.last_used_at, ) .fetch_one(&mut *self.0) .await?; - Ok(secret) + Ok(()) } pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> { @@ -67,18 +56,15 @@ impl Tokens<'_> { Ok(()) } - // Revoke a token by its secret. - pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> { - sqlx::query_scalar!( + pub async fn revoke(&mut self, token: &Token) -> Result<(), sqlx::Error> { + sqlx::query!( r#" - delete - from token + delete from token where id = $1 - returning id as "id: Id" "#, - token, + token.id, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; Ok(()) @@ -127,24 +113,31 @@ impl Tokens<'_> { &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Id, History), LoadError> { + ) -> Result<(Token, History), LoadError> { // I would use `update … returning` to do this in one query, but // sqlite3, as of this writing, does not allow an update's `returning` // clause to reference columns from tables joined into the update. Two // queries is fine, but it feels untidy. - let (token, user) = sqlx::query!( + let token = sqlx::query!( r#" update token set last_used_at = $1 where secret = $2 returning - id as "token: Id", - login as "login: user::Id" + id as "id: Id", + login as "login: user::Id", + issued_at as "issued_at: DateTime", + last_used_at as "last_used_at: DateTime" "#, used_at, secret, ) - .map(|row| (row.token, row.login)) + .map(|row| Token { + id: row.id, + user: row.login, + issued_at: row.issued_at, + last_used_at: row.last_used_at, + }) .fetch_one(&mut *self.0) .await?; @@ -160,7 +153,7 @@ impl Tokens<'_> { join login using (id) where id = $1 "#, - user, + token.user, ) .map(|row| { Ok::<_, name::Error>(History { diff --git a/src/user/history.rs b/src/user/history.rs index 4f99130..72e0aee 100644 --- a/src/user/history.rs +++ b/src/user/history.rs @@ -20,7 +20,6 @@ impl History { // if this returns a redacted or modified version of the user. If we implement // renames by redacting the original name, then this should return the edited // user, not the original, even if that's not how it was "as created.") - #[cfg(test)] pub fn as_created(&self) -> User { self.user.clone() } -- cgit v1.2.3 From d0d5fa20200a7ad70173ba87ae47c33b60f44a3b Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 26 Aug 2025 00:44:29 -0400 Subject: Split `user` into a chat-facing entity and an authentication-facing entity. The taxonomy is now as follows: * A _login_ is someone's identity for the purposes of authenticating to the service. Logins are not synchronized, and in fact are not published anywhere in the current API. They have a login ID, a name and a password. * A _user_ is someone's identity for the purpose of participating in conversations. Users _are_ synchronized, as before. They have a user ID, a name, and a creation instant for the purposes of synchronization. In practice, a user exists for every login - in fact, users' names are stored in the login table and are joined in, rather than being stored redundantly in the user table. A login ID and its corresponding user ID are always equal, and the user and login ID types support conversion and comparison to facilitate their use in this context. Tokens are now associated with logins, not users. The currently-acting identity is passed down into app types as a login, not a user, and then resolved to a user where appropriate within the app methods. As a side effect, the `GET /api/boot` method now returns a `login` key instead of a `user` key. The structure of the nested value is unchanged. --- ...c457fd85d122664028549a71b0f7f74f8899523271.json | 38 ++++++ ...578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json | 50 ------- ...22966d85ded0cbd27125093208d7f7f0aabb0ea61b.json | 50 ------- ...6678c9709a8637630faa759d404ebd4eb2545dab72.json | 38 ------ ...4f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json | 44 ------- ...38efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json | 38 ++++++ ...bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json | 12 ++ ...38fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json | 32 +++++ ...0c38e4cd431247831fd9f51fa0db77098cf872411e.json | 44 +++++++ ...ac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json | 38 ++++++ ...c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json | 20 --- docs/api/boot.md | 9 +- src/app.rs | 11 +- src/boot/handlers/boot/mod.rs | 8 +- src/boot/handlers/boot/test.rs | 4 +- src/conversation/handlers/send/mod.rs | 7 +- src/conversation/handlers/send/test.rs | 2 +- src/event/handlers/stream/test/invite.rs | 4 +- src/event/handlers/stream/test/setup.rs | 2 +- src/event/handlers/stream/test/token.rs | 4 +- src/invite/app.rs | 48 ++++++- src/invite/handlers/accept/test.rs | 4 +- src/invite/handlers/issue/mod.rs | 2 +- src/invite/handlers/issue/test.rs | 2 +- src/lib.rs | 1 + src/login/app.rs | 114 ++++++++++++++++ src/login/handlers/login/mod.rs | 51 ++++++++ src/login/handlers/login/test.rs | 114 ++++++++++++++++ src/login/handlers/logout/mod.rs | 51 ++++++++ src/login/handlers/logout/test.rs | 71 ++++++++++ src/login/handlers/mod.rs | 7 + src/login/handlers/password/mod.rs | 56 ++++++++ src/login/handlers/password/test.rs | 47 +++++++ src/login/id.rs | 27 ++++ src/login/mod.rs | 13 ++ src/login/repo.rs | 145 +++++++++++++++++++++ src/message/app.rs | 121 ++++++++++++----- src/message/handlers/delete/mod.rs | 11 +- src/message/handlers/delete/test.rs | 8 +- src/message/history.rs | 15 ++- src/message/repo.rs | 10 +- src/routes.rs | 8 +- src/setup/app.rs | 4 +- src/test/fixtures/cookie.rs | 14 +- src/test/fixtures/identity.rs | 6 +- src/test/fixtures/invite.rs | 4 +- src/test/fixtures/login.rs | 21 +++ src/test/fixtures/message.rs | 4 +- src/test/fixtures/mod.rs | 1 + src/test/fixtures/user.rs | 25 +--- src/test/verify/identity.rs | 6 +- src/test/verify/login.rs | 10 +- src/test/verify/token.rs | 8 +- src/token/app.rs | 118 +---------------- src/token/extract/identity.rs | 4 +- src/token/mod.rs | 12 +- src/token/repo/auth.rs | 105 --------------- src/token/repo/mod.rs | 1 - src/token/repo/token.rs | 43 +++--- src/user/app.rs | 14 +- src/user/create.rs | 28 ++-- src/user/handlers/login/mod.rs | 56 -------- src/user/handlers/login/test.rs | 110 ---------------- src/user/handlers/logout/mod.rs | 53 -------- src/user/handlers/logout/test.rs | 72 ---------- src/user/handlers/mod.rs | 7 - src/user/handlers/password/mod.rs | 58 --------- src/user/handlers/password/test.rs | 47 ------- src/user/history.rs | 26 ++-- src/user/id.rs | 17 +++ src/user/mod.rs | 3 +- src/user/repo.rs | 89 ++++++------- ui/lib/session.svelte.js | 8 +- 73 files changed, 1265 insertions(+), 1090 deletions(-) create mode 100644 .sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json delete mode 100644 .sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json delete mode 100644 .sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json delete mode 100644 .sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json delete mode 100644 .sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json create mode 100644 .sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json create mode 100644 .sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json create mode 100644 .sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json create mode 100644 .sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json create mode 100644 .sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json delete mode 100644 .sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json create mode 100644 src/login/app.rs create mode 100644 src/login/handlers/login/mod.rs create mode 100644 src/login/handlers/login/test.rs create mode 100644 src/login/handlers/logout/mod.rs create mode 100644 src/login/handlers/logout/test.rs create mode 100644 src/login/handlers/mod.rs create mode 100644 src/login/handlers/password/mod.rs create mode 100644 src/login/handlers/password/test.rs create mode 100644 src/login/id.rs create mode 100644 src/login/mod.rs create mode 100644 src/login/repo.rs create mode 100644 src/test/fixtures/login.rs delete mode 100644 src/token/repo/auth.rs delete mode 100644 src/user/handlers/login/mod.rs delete mode 100644 src/user/handlers/login/test.rs delete mode 100644 src/user/handlers/logout/mod.rs delete mode 100644 src/user/handlers/logout/test.rs delete mode 100644 src/user/handlers/mod.rs delete mode 100644 src/user/handlers/password/mod.rs delete mode 100644 src/user/handlers/password/test.rs (limited to 'src/user') diff --git a/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json new file mode 100644 index 0000000..ca93083 --- /dev/null +++ b/.sqlx/query-047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: login::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issued_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_used_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "047e4f40c16eaf59c9b17cc457fd85d122664028549a71b0f7f74f8899523271" +} diff --git a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json b/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json deleted file mode 100644 index 4bc5119..0000000 --- a/.sqlx/query-0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: user::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "password: StoredHash", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "0bd9ba049b839f3187dc74578f26eca4b9f1850dfc30734b516e8cdd17f92a5c" -} diff --git a/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json b/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json deleted file mode 100644 index f4ae749..0000000 --- a/.sqlx/query-20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\",\n login.password as \"password: StoredHash\"\n from user\n join login using (id)\n where login.canonical_name = $1\n ", - "describe": { - "columns": [ - { - "name": "id: user::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "password: StoredHash", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "20e525a7b6f354e99b9c6722966d85ded0cbd27125093208d7f7f0aabb0ea61b" -} diff --git a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json deleted file mode 100644 index 99a05e3..0000000 --- a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: user::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "login: user::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "issued_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "last_used_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72" -} diff --git a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json b/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json deleted file mode 100644 index 1a4389f..0000000 --- a/.sqlx/query-48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: user::Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_sequence as \"created_sequence: Sequence\",\n user.created_at as \"created_at: DateTime\"\n from user\n join login using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: user::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "canonical_name: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "48f407dc8e63236f2f30634f7c249ec8f6c5e2c3a30a9995738de714c8b53509" -} diff --git a/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json b/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json new file mode 100644 index 0000000..b5c9c81 --- /dev/null +++ b/.sqlx/query-490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name,\n canonical_name,\n password as \"password: StoredHash\"\n from login\n where canonical_name = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password: StoredHash", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "490fff3d9e2a551e6209c638efe1bd1d68b3ba5b6c35fd62be0edffc97884079" +} diff --git a/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json b/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json new file mode 100644 index 0000000..05d04e3 --- /dev/null +++ b/.sqlx/query-94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n update login\n set password = $1\n where id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "94f7be20e34125b1b28212bc6d13aed7e8b41c5bfff148e2ddbb3baeb5252b95" +} diff --git a/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json b/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json new file mode 100644 index 0000000..56232dd --- /dev/null +++ b/.sqlx/query-9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: login::Id\",\n display_name,\n canonical_name\n from login\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "9dca923dde1cb3c5ddb44138fd274cc9cfeba2a5ec5d9b7cd68ecad635a1b60a" +} diff --git a/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json new file mode 100644 index 0000000..789151d --- /dev/null +++ b/.sqlx/query-c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n login.display_name as \"display_name: String\",\n login.canonical_name as \"canonical_name: String\",\n user.created_at as \"created_at: DateTime\",\n user.created_sequence as \"created_sequence: Sequence\"\n from user\n join login using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c20009de34e660bd60f4fc0c38e4cd431247831fd9f51fa0db77098cf872411e" +} diff --git a/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json b/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json new file mode 100644 index 0000000..00b0ab9 --- /dev/null +++ b/.sqlx/query-c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name,\n canonical_name,\n password as \"password: StoredHash\"\n from login\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password: StoredHash", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "c372be289eaa951488b1bbac7300a50de2ae444f7a57ee3ef58e4b388ce24389" +} diff --git a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json b/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json deleted file mode 100644 index 200c357..0000000 --- a/.sqlx/query-cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update login\n set password = $1\n where id = $2\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false - ] - }, - "hash": "cc75dd8bb08771698f0156c6fccd5ef25a4a7040f8e5095bc7dc5265f43b0d9c" -} diff --git a/docs/api/boot.md b/docs/api/boot.md index d7e9144..cc59d79 100644 --- a/docs/api/boot.md +++ b/docs/api/boot.md @@ -37,7 +37,7 @@ This endpoint will respond with a status of ```json { - "user": { + "login": { "name": "example username", "id": "U1234abcd" }, @@ -100,15 +100,14 @@ The response will include the following fields: | Field | Type | Description | | :------------- | :-------------- | :----------------------------------------------------------------------------------------------------------------------- | -| `user` | object | The details of the caller's identity. | +| `login` | object | The details of the caller's identity. | | `resume_point` | integer | A resume point for [events](./events.md), such that the event stream will begin immediately after the included snapshot. | | `heartbeat` | integer | The [heartbeat timeout](./events.md#heartbeat-events), in seconds, for events. | | `events` | array of object | The events on the server up to the resume point. | -Each element of the -`events` object is an event, as described in [Events](./events.md). Events are provided in the same order as they would appear in the event stream response. +Each element of the `events` object is an event, as described in [Events](./events.md). Events are provided in the same order as they would appear in the event stream response. -The `user` object will include the following fields: +The `login` object will include the following fields: | Field | Type | Description | | :----- | :----- | :--------------------------------------- | diff --git a/src/app.rs b/src/app.rs index ab8da7e..d0ffcc0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use crate::{ conversation::app::Conversations, event::{self, app::Events}, invite::app::Invites, + login::app::Logins, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, @@ -49,9 +50,8 @@ impl App { Invites::new(&self.db, &self.events) } - #[cfg(test)] - pub const fn users(&self) -> Users<'_> { - Users::new(&self.db, &self.events) + pub const fn logins(&self) -> Logins<'_> { + Logins::new(&self.db, &self.token_events) } pub const fn messages(&self) -> Messages<'_> { @@ -65,4 +65,9 @@ impl App { pub const fn tokens(&self) -> Tokens<'_> { Tokens::new(&self.db, &self.token_events) } + + #[cfg(test)] + pub const fn users(&self) -> Users<'_> { + Users::new(&self.db, &self.events) + } } diff --git a/src/boot/handlers/boot/mod.rs b/src/boot/handlers/boot/mod.rs index 49691f7..3e022b1 100644 --- a/src/boot/handlers/boot/mod.rs +++ b/src/boot/handlers/boot/mod.rs @@ -7,8 +7,8 @@ use axum::{ use serde::Serialize; use crate::{ - app::App, boot::Snapshot, error::Internal, event::Heartbeat, token::extract::Identity, - user::User, + app::App, boot::Snapshot, error::Internal, event::Heartbeat, login::Login, + token::extract::Identity, }; #[cfg(test)] @@ -19,7 +19,7 @@ pub async fn handler(State(app): State, identity: Identity) -> Result, identity: Identity) -> Result Result { let message = app .messages() - .send(&conversation, &identity.user, &sent_at, &request.body) + .send(&conversation, &identity.login, &sent_at, &request.body) .await?; Ok(Response(message)) @@ -57,7 +57,10 @@ impl IntoResponse for Error { SendError::ConversationNotFound(_) | SendError::ConversationDeleted(_) => { NotFound(error).into_response() } - SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(), + SendError::SenderNotFound(_) + | SendError::SenderDeleted(_) + | SendError::Name(_) + | SendError::Database(_) => Internal::from(error).into_response(), } } } diff --git a/src/conversation/handlers/send/test.rs b/src/conversation/handlers/send/test.rs index bd32510..8863090 100644 --- a/src/conversation/handlers/send/test.rs +++ b/src/conversation/handlers/send/test.rs @@ -55,7 +55,7 @@ async fn messages_in_order() { .await { assert_eq!(*sent_at, event.at()); - assert_eq!(sender.user.id, event.message.sender); + assert_eq!(sender.login.id, event.message.sender); assert_eq!(body, event.message.body); } } diff --git a/src/event/handlers/stream/test/invite.rs b/src/event/handlers/stream/test/invite.rs index ce22384..d4ac82d 100644 --- a/src/event/handlers/stream/test/invite.rs +++ b/src/event/handlers/stream/test/invite.rs @@ -44,7 +44,7 @@ async fn accepting_invite() { let _ = events .filter_map(fixtures::event::stream::user) .filter_map(fixtures::event::stream::user::created) - .filter(|event| future::ready(event.user == joiner.user)) + .filter(|event| future::ready(event.user.id == joiner.login.id)) .next() .expect_some("a login created event is sent") .await; @@ -90,7 +90,7 @@ async fn previously_accepted_invite() { let _ = events .filter_map(fixtures::event::stream::user) .filter_map(fixtures::event::stream::user::created) - .filter(|event| future::ready(event.user == joiner.user)) + .filter(|event| future::ready(event.user.id == joiner.login.id)) .next() .expect_some("a login created event is sent") .await; diff --git a/src/event/handlers/stream/test/setup.rs b/src/event/handlers/stream/test/setup.rs index 1fd2b7c..3f01ccb 100644 --- a/src/event/handlers/stream/test/setup.rs +++ b/src/event/handlers/stream/test/setup.rs @@ -45,7 +45,7 @@ async fn previously_completed() { let _ = events .filter_map(fixtures::event::stream::user) .filter_map(fixtures::event::stream::user::created) - .filter(|event| future::ready(event.user == owner.user)) + .filter(|event| future::ready(event.user.id == owner.login.id)) .next() .expect_some("a login created event is sent") .await; diff --git a/src/event/handlers/stream/test/token.rs b/src/event/handlers/stream/test/token.rs index 5af07a0..a9dfe29 100644 --- a/src/event/handlers/stream/test/token.rs +++ b/src/event/handlers/stream/test/token.rs @@ -125,8 +125,8 @@ async fn terminates_on_password_change() { let (_, password) = creds; let to = fixtures::user::propose_password(); - app.tokens() - .change_password(&subscriber.user, &password, &to, &fixtures::now()) + app.logins() + .change_password(&subscriber.login, &password, &to, &fixtures::now()) .await .expect("expiring tokens succeeds"); diff --git a/src/invite/app.rs b/src/invite/app.rs index 6e235b2..6684d03 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -5,13 +5,15 @@ use super::{Id, Invite, Summary, repo::Provider as _}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, - event::Broadcaster, - name::Name, + event::{Broadcaster, repo::Provider as _}, + login::Login, + name::{self, Name}, password::Password, token::{Secret, Token, repo::Provider as _}, user::{ - User, + self, create::{self, Create}, + repo::{LoadError, Provider as _}, }, }; @@ -25,9 +27,19 @@ impl<'a> Invites<'a> { Self { db, events } } - pub async fn issue(&self, issuer: &User, issued_at: &DateTime) -> Result { + pub async fn issue(&self, issuer: &Login, issued_at: &DateTime) -> Result { + let issuer_not_found = || Error::IssuerNotFound(issuer.id.clone().into()); + let issuer_deleted = || Error::IssuerDeleted(issuer.id.clone().into()); + let mut tx = self.db.begin().await?; - let invite = tx.invites().create(issuer, issued_at).await?; + let issuer = tx + .users() + .by_login(issuer) + .await + .not_found(issuer_not_found)?; + let now = tx.sequence().current().await?; + let issuer = issuer.as_of(now).ok_or_else(issuer_deleted)?; + let invite = tx.invites().create(&issuer, issued_at).await?; tx.commit().await?; Ok(invite) @@ -71,8 +83,8 @@ impl<'a> Invites<'a> { .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let user = stored.user().as_created(); - let (token, secret) = Token::generate(&user, accepted_at); + let login = stored.login(); + let (token, secret) = Token::generate(login, accepted_at); tx.tokens().create(&token, &secret).await?; tx.commit().await?; @@ -93,6 +105,28 @@ impl<'a> Invites<'a> { } } +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("issuing user {0} not found")] + IssuerNotFound(user::Id), + #[error("issuing user {0} deleted")] + IssuerDeleted(user::Id), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for Error { + fn from(error: LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum AcceptError { #[error("invite not found: {0}")] diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs index 4e4a09d..283ec76 100644 --- a/src/invite/handlers/accept/test.rs +++ b/src/invite/handlers/accept/test.rs @@ -44,8 +44,8 @@ async fn valid_invite() { // Verify that the given credentials can log in let secret = app - .tokens() - .login(&name, &password, &fixtures::now()) + .logins() + .with_password(&name, &password, &fixtures::now()) .await .expect("credentials given on signup are valid"); verify::token::valid_for_name(&app, &secret, &name).await; diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs index 6085f7a..4ac74cc 100644 --- a/src/invite/handlers/issue/mod.rs +++ b/src/invite/handlers/issue/mod.rs @@ -13,7 +13,7 @@ pub async fn handler( identity: Identity, _: Json, ) -> Result, Internal> { - let invite = app.invites().issue(&identity.user, &issued_at).await?; + let invite = app.invites().issue(&identity.login, &issued_at).await?; Ok(Json(invite)) } diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs index 2bf5400..4421705 100644 --- a/src/invite/handlers/issue/test.rs +++ b/src/invite/handlers/issue/test.rs @@ -22,6 +22,6 @@ async fn create_invite() { .expect("creating an invite always succeeds"); // Verify the response - assert_eq!(issuer.user.id, invite.issuer); + assert_eq!(issuer.login.id, invite.issuer); assert_eq!(&*issued_at, &invite.issued_at); } diff --git a/src/lib.rs b/src/lib.rs index b3299d7..f05cce3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod event; mod expire; mod id; mod invite; +mod login; mod message; mod name; mod normalize; diff --git a/src/login/app.rs b/src/login/app.rs new file mode 100644 index 0000000..77d4ac3 --- /dev/null +++ b/src/login/app.rs @@ -0,0 +1,114 @@ +use sqlx::sqlite::SqlitePool; + +use crate::{ + clock::DateTime, + db::NotFound as _, + login::{self, Login, repo::Provider as _}, + name::{self, Name}, + password::Password, + token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _}, +}; + +pub struct Logins<'a> { + db: &'a SqlitePool, + token_events: &'a Broadcaster, +} + +impl<'a> Logins<'a> { + pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self { + Self { db, token_events } + } + + pub async fn with_password( + &self, + name: &Name, + candidate: &Password, + login_at: &DateTime, + ) -> Result { + let mut tx = self.db.begin().await?; + let (login, password) = tx + .logins() + .by_name(name) + .await + .not_found(|| LoginError::Rejected)?; + // Split the transaction here to avoid holding the tx open (potentially blocking + // other writes) while we do the fairly expensive task of verifying the + // password. It's okay if the token issuance transaction happens some notional + // amount of time after retrieving the login, as inserting the token will fail + // if the account is deleted during that time. + tx.commit().await?; + + if password.verify(candidate)? { + let mut tx = self.db.begin().await?; + let (token, secret) = Token::generate(&login, login_at); + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; + Ok(secret) + } else { + Err(LoginError::Rejected) + } + } + + pub async fn change_password( + &self, + login: &Login, + from: &Password, + to: &Password, + changed_at: &DateTime, + ) -> Result { + let mut tx = self.db.begin().await?; + let (login, password) = tx + .logins() + .by_id(&login.id) + .await + .not_found(|| LoginError::Rejected)?; + // Split the transaction here to avoid holding the tx open (potentially blocking + // other writes) while we do the fairly expensive task of verifying the + // password. It's okay if the token issuance transaction happens some notional + // amount of time after retrieving the login, as inserting the token will fail + // if the account is deleted during that time. + tx.commit().await?; + + if password.verify(from)? { + let to_hash = to.hash()?; + let (token, secret) = Token::generate(&login, changed_at); + + let mut tx = self.db.begin().await?; + tx.logins().set_password(&login, &to_hash).await?; + + let revoked = tx.tokens().revoke_all(&login).await?; + tx.tokens().create(&token, &secret).await?; + tx.commit().await?; + + for event in revoked.into_iter().map(TokenEvent::Revoked) { + self.token_events.broadcast(event); + } + + Ok(secret) + } else { + Err(LoginError::Rejected) + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("invalid login")] + Rejected, + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} + +impl From for LoginError { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} diff --git a/src/login/handlers/login/mod.rs b/src/login/handlers/login/mod.rs new file mode 100644 index 0000000..6591984 --- /dev/null +++ b/src/login/handlers/login/mod.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name, + password::Password, token::extract::IdentityCookie, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Empty), Error> { + let secret = app + .logins() + .with_password(&request.name, &request.password, &now) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, Empty)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + // not error::Unauthorized due to differing messaging + (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/handlers/login/test.rs b/src/login/handlers/login/test.rs new file mode 100644 index 0000000..f3911d0 --- /dev/null +++ b/src/login/handlers/login/test.rs @@ -0,0 +1,114 @@ +use axum::extract::{Json, State}; + +use crate::{ + empty::Empty, + login::app::LoginError, + test::{fixtures, verify}, +}; + +#[tokio::test] +async fn correct_credentials() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let logged_in_at = fixtures::now(); + let request = super::Request { + name: name.clone(), + password, + }; + let (identity, Empty) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + let secret = identity + .secret() + .expect("logged in with valid credentials issues an identity cookie"); + + // Verify the semantics + verify::token::valid_for_name(&app, &secret, &name).await; +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let logged_in_at = fixtures::now(); + let (name, password) = fixtures::user::propose(); + let request = super::Request { + name: name.clone(), + password, + }; + let super::Error(error) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password fails"); + + // Verify the return value's basic structure + + assert!(matches!(error, LoginError::Rejected)); +} + +#[tokio::test] +async fn incorrect_password() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::user::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::now(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: login.name, + password: fixtures::user::propose_password(), + }; + let super::Error(error) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, LoginError::Rejected)); +} + +#[tokio::test] +async fn token_expires() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::ancient(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { name, password }; + let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + app.tokens() + .expire(&fixtures::now()) + .await + .expect("expiring tokens never fails"); + + verify::token::invalid(&app, &secret).await; +} diff --git a/src/login/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs new file mode 100644 index 0000000..73efe73 --- /dev/null +++ b/src/login/handlers/logout/mod.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, State}, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + empty::Empty, + error::{Internal, Unauthorized}, + token::{app, extract::IdentityCookie}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityCookie, + Json(_): Json, +) -> Result<(IdentityCookie, Empty), Error> { + if let Some(secret) = identity.secret() { + let identity = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&identity.token).await?; + } + + let identity = identity.clear(); + Ok((identity, Empty)) +} + +// This forces the only valid request to be `{}`, and not the infinite +// variation allowed when there's no body extractor. +#[derive(Default, serde::Deserialize)] +pub struct Request {} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::ValidateError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::ValidateError::InvalidToken => Unauthorized.into_response(), + app::ValidateError::Name(_) | app::ValidateError::Database(_) => { + Internal::from(error).into_response() + } + } + } +} diff --git a/src/login/handlers/logout/test.rs b/src/login/handlers/logout/test.rs new file mode 100644 index 0000000..e7b7dd4 --- /dev/null +++ b/src/login/handlers/logout/test.rs @@ -0,0 +1,71 @@ +use axum::extract::{Json, State}; + +use crate::{ + empty::Empty, + test::{fixtures, verify}, + token::app, +}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let now = fixtures::now(); + let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; + + // Call the endpoint + + let (response_identity, Empty) = super::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + Json::default(), + ) + .await + .expect("logged out with a valid token"); + + // Verify the return value's basic structure + assert!(response_identity.secret().is_none()); + + // Verify the semantics + verify::identity::invalid(&app, &identity).await; +} + +#[tokio::test] +async fn no_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let (identity, Empty) = super::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect("logged out with no token succeeds"); + + // Verify the return value's basic structure + + assert!(identity.secret().is_none()); +} + +#[tokio::test] +async fn invalid_token() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::fictitious(); + let super::Error(error) = + super::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect_err("logged out with an invalid token fails"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/login/handlers/mod.rs b/src/login/handlers/mod.rs new file mode 100644 index 0000000..24ee7f9 --- /dev/null +++ b/src/login/handlers/mod.rs @@ -0,0 +1,7 @@ +mod login; +mod logout; +pub mod password; + +pub use login::handler as login; +pub use logout::handler as logout; +pub use password::handler as change_password; diff --git a/src/login/handlers/password/mod.rs b/src/login/handlers/password/mod.rs new file mode 100644 index 0000000..94c7fb4 --- /dev/null +++ b/src/login/handlers/password/mod.rs @@ -0,0 +1,56 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + empty::Empty, + error::Internal, + login::app, + password::Password, + token::extract::{Identity, IdentityCookie}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: Identity, + cookie: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Empty), Error> { + let secret = app + .logins() + .change_password(&identity.login, &request.password, &request.to, &now) + .await + .map_err(Error)?; + let cookie = cookie.set(secret); + Ok((cookie, Empty)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub password: Password, + pub to: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + (StatusCode::BAD_REQUEST, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/handlers/password/test.rs b/src/login/handlers/password/test.rs new file mode 100644 index 0000000..ba2f28f --- /dev/null +++ b/src/login/handlers/password/test.rs @@ -0,0 +1,47 @@ +use axum::extract::{Json, State}; + +use crate::{ + empty::Empty, + test::{fixtures, verify}, +}; + +#[tokio::test] +async fn password_change() { + // Set up the environment + let app = fixtures::scratch_app().await; + let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; + let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; + + // Call the endpoint + let (name, password) = creds; + let to = fixtures::user::propose_password(); + let request = super::Request { + password: password.clone(), + to: to.clone(), + }; + let (new_cookie, Empty) = super::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + cookie.clone(), + Json(request), + ) + .await + .expect("changing passwords succeeds"); + + // Verify that we have a new session + assert_ne!(cookie.secret(), new_cookie.secret()); + + // Verify that we're still ourselves + verify::identity::valid_for_login(&app, &new_cookie, &identity.login).await; + + // Verify that our original token is no longer valid + verify::identity::invalid(&app, &cookie).await; + + // Verify that our original password is no longer valid + verify::login::invalid_login(&app, &name, &password).await; + + // Verify that our new password is valid + verify::login::valid_login(&app, &name, &to).await; +} diff --git a/src/login/id.rs b/src/login/id.rs new file mode 100644 index 0000000..ab16a15 --- /dev/null +++ b/src/login/id.rs @@ -0,0 +1,27 @@ +use crate::user; + +pub type Id = crate::id::Id; + +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From for Id { + fn from(user: user::Id) -> Self { + Self::from(String::from(user)) + } +} + +impl PartialEq for Id { + fn eq(&self, other: &user::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Login; + +impl crate::id::Prefix for Login { + fn prefix(&self) -> &'static str { + user::id::User.prefix() + } +} diff --git a/src/login/mod.rs b/src/login/mod.rs new file mode 100644 index 0000000..bccc2af --- /dev/null +++ b/src/login/mod.rs @@ -0,0 +1,13 @@ +pub mod app; +pub mod handlers; +mod id; +pub mod repo; + +use crate::name::Name; +pub use id::Id; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct Login { + pub id: Id, + pub name: Name, +} diff --git a/src/login/repo.rs b/src/login/repo.rs new file mode 100644 index 0000000..5be91ad --- /dev/null +++ b/src/login/repo.rs @@ -0,0 +1,145 @@ +use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; + +use super::{Id, Login}; +use crate::{ + db::NotFound, + name::{self, Name}, + password::StoredHash, +}; + +pub trait Provider { + fn logins(&mut self) -> Logins<'_>; +} + +impl Provider for Transaction<'_, Sqlite> { + fn logins(&mut self) -> Logins<'_> { + Logins(self) + } +} + +pub struct Logins<'t>(&'t mut SqliteConnection); + +impl Logins<'_> { + pub async fn create( + &mut self, + login: &Login, + password: &StoredHash, + ) -> Result<(), sqlx::Error> { + let Login { id, name } = login; + let display_name = name.display(); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + insert into login (id, display_name, canonical_name, password) + values ($1, $2, $3, $4) + "#, + id, + display_name, + canonical_name, + password, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } + + pub async fn by_id(&mut self, id: &Id) -> Result<(Login, StoredHash), LoadError> { + let user = sqlx::query!( + r#" + select + id as "id: Id", + display_name, + canonical_name, + password as "password: StoredHash" + from login + where id = $1 + "#, + id, + ) + .map(|row| { + Ok::<_, LoadError>(( + Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + row.password, + )) + }) + .fetch_one(&mut *self.0) + .await??; + + Ok(user) + } + + pub async fn by_name(&mut self, name: &Name) -> Result<(Login, StoredHash), LoadError> { + let canonical_name = name.canonical(); + + let (login, password) = sqlx::query!( + r#" + select + id as "id: Id", + display_name, + canonical_name, + password as "password: StoredHash" + from login + where canonical_name = $1 + "#, + canonical_name, + ) + .map(|row| { + Ok::<_, LoadError>(( + Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + row.password, + )) + }) + .fetch_one(&mut *self.0) + .await??; + + Ok((login, password)) + } + + pub async fn set_password( + &mut self, + login: &Login, + password: &StoredHash, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + update login + set password = $1 + where id = $2 + "#, + password, + login.id, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl NotFound for Result { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/src/message/app.rs b/src/message/app.rs index bdc2164..9100224 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -8,8 +8,9 @@ use crate::{ conversation::{self, repo::Provider as _}, db::NotFound as _, event::{Broadcaster, Event, Sequence, repo::Provider as _}, + login::Login, name, - user::User, + user::{self, repo::Provider as _}, }; pub struct Messages<'a> { @@ -25,27 +26,35 @@ impl<'a> Messages<'a> { pub async fn send( &self, conversation: &conversation::Id, - sender: &User, + sender: &Login, sent_at: &DateTime, body: &Body, ) -> Result { - let to_not_found = || SendError::ConversationNotFound(conversation.clone()); - let to_deleted = || SendError::ConversationDeleted(conversation.clone()); + let conversation_not_found = || SendError::ConversationNotFound(conversation.clone()); + let conversation_deleted = || SendError::ConversationDeleted(conversation.clone()); + let sender_not_found = || SendError::SenderNotFound(sender.id.clone().into()); + let sender_deleted = || SendError::SenderDeleted(sender.id.clone().into()); let mut tx = self.db.begin().await?; let conversation = tx .conversations() .by_id(conversation) .await - .not_found(to_not_found)?; + .not_found(conversation_not_found)?; + let sender = tx + .users() + .by_login(sender) + .await + .not_found(sender_not_found)?; // Ordering: don't bother allocating a sequence number before we know the channel might // exist. let sent = tx.sequence().next(sent_at).await?; - let conversation = conversation.as_of(sent).ok_or_else(to_deleted)?; + let conversation = conversation.as_of(sent).ok_or_else(conversation_deleted)?; + let sender = sender.as_of(sent).ok_or_else(sender_deleted)?; let message = tx .messages() - .create(&conversation, sender, &sent, body) + .create(&conversation, &sender, &sent, body) .await?; tx.commit().await?; @@ -57,36 +66,48 @@ impl<'a> Messages<'a> { pub async fn delete( &self, - deleted_by: &User, + deleted_by: &Login, message: &Id, deleted_at: &DateTime, ) -> Result<(), DeleteError> { + let message_not_found = || DeleteError::MessageNotFound(message.clone()); + let message_deleted = || DeleteError::Deleted(message.clone()); + let deleter_not_found = || DeleteError::UserNotFound(deleted_by.id.clone().into()); + let deleter_deleted = || DeleteError::UserDeleted(deleted_by.id.clone().into()); + let not_sender = || DeleteError::NotSender(deleted_by.id.clone().into()); + let mut tx = self.db.begin().await?; let message = tx .messages() .by_id(message) .await - .not_found(|| DeleteError::NotFound(message.clone()))?; - let snapshot = message - .as_snapshot() - .ok_or_else(|| DeleteError::Deleted(message.id().clone()))?; - if snapshot.sender != deleted_by.id { - return Err(DeleteError::NotSender(deleted_by.clone())); - } + .not_found(message_not_found)?; + let deleted_by = tx + .users() + .by_login(deleted_by) + .await + .not_found(deleter_not_found)?; let deleted = tx.sequence().next(deleted_at).await?; - let message = tx.messages().delete(&message, &deleted).await?; - tx.commit().await?; + let message = message.as_of(deleted).ok_or_else(message_deleted)?; + let deleted_by = deleted_by.as_of(deleted).ok_or_else(deleter_deleted)?; - self.events.broadcast( - message - .events() - .filter(Sequence::start_from(deleted.sequence)) - .map(Event::from) - .collect::>(), - ); + if message.sender == deleted_by.id { + let message = tx.messages().delete(&message, &deleted).await?; + tx.commit().await?; - Ok(()) + self.events.broadcast( + message + .events() + .filter(Sequence::start_from(deleted.sequence)) + .map(Event::from) + .collect::>(), + ); + + Ok(()) + } else { + Err(not_sender()) + } } pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { @@ -99,12 +120,14 @@ impl<'a> Messages<'a> { let mut events = Vec::with_capacity(expired.len()); for message in expired { let deleted = tx.sequence().next(relative_to).await?; - let message = tx.messages().delete(&message, &deleted).await?; - events.push( - message - .events() - .filter(Sequence::start_from(deleted.sequence)), - ); + if let Some(message) = message.as_of(deleted) { + let message = tx.messages().delete(&message, &deleted).await?; + events.push( + message + .events() + .filter(Sequence::start_from(deleted.sequence)), + ); + } } tx.commit().await?; @@ -138,6 +161,10 @@ pub enum SendError { ConversationNotFound(conversation::Id), #[error("conversation {0} deleted")] ConversationDeleted(conversation::Id), + #[error("user {0} not found")] + SenderNotFound(user::Id), + #[error("user {0} deleted")] + SenderDeleted(user::Id), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] @@ -154,14 +181,40 @@ impl From for SendError { } } +impl From for SendError { + fn from(error: user::repo::LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum DeleteError { #[error("message {0} not found")] - NotFound(Id), - #[error("user {} not the message's sender", .0.id)] - NotSender(User), + MessageNotFound(Id), + #[error("user {0} not found")] + UserNotFound(user::Id), + #[error("user {0} deleted")] + UserDeleted(user::Id), + #[error("user {0} not the message's sender")] + NotSender(user::Id), #[error("message {0} deleted")] Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for DeleteError { + fn from(error: user::repo::LoadError) -> Self { + use user::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs index 5eac4eb..606f502 100644 --- a/src/message/handlers/delete/mod.rs +++ b/src/message/handlers/delete/mod.rs @@ -22,7 +22,7 @@ pub async fn handler( identity: Identity, ) -> Result { app.messages() - .delete(&identity.user, &message, &deleted_at) + .delete(&identity.login, &message, &deleted_at) .await?; Ok(Response { id: message }) @@ -48,8 +48,13 @@ impl IntoResponse for Error { let Self(error) = self; match error { DeleteError::NotSender(_) => (StatusCode::FORBIDDEN, error.to_string()).into_response(), - DeleteError::NotFound(_) | DeleteError::Deleted(_) => NotFound(error).into_response(), - DeleteError::Database(_) => Internal::from(error).into_response(), + DeleteError::MessageNotFound(_) | DeleteError::Deleted(_) => { + NotFound(error).into_response() + } + DeleteError::UserNotFound(_) + | DeleteError::UserDeleted(_) + | DeleteError::Database(_) + | DeleteError::Name(_) => Internal::from(error).into_response(), } } } diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs index 371c7bf..d0e1794 100644 --- a/src/message/handlers/delete/test.rs +++ b/src/message/handlers/delete/test.rs @@ -11,7 +11,7 @@ pub async fn delete_message() { let sender = fixtures::identity::create(&app, &fixtures::now()).await; let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let message = - fixtures::message::send(&app, &conversation, &sender.user, &fixtures::now()).await; + fixtures::message::send(&app, &conversation, &sender.login, &fixtures::now()).await; // Send the request @@ -62,7 +62,7 @@ pub async fn delete_invalid_message_id() { // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message)); + assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message)); } #[tokio::test] @@ -160,7 +160,7 @@ pub async fn delete_purged() { // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id)); + assert!(matches!(error, app::DeleteError::MessageNotFound(id) if id == message.id)); } #[tokio::test] @@ -187,6 +187,6 @@ pub async fn delete_not_sender() { // Verify the response assert!( - matches!(error, app::DeleteError::NotSender(error_sender) if deleter.user == error_sender) + matches!(error, app::DeleteError::NotSender(error_sender) if deleter.login.id == error_sender) ); } diff --git a/src/message/history.rs b/src/message/history.rs index d4d4500..2abdf2c 100644 --- a/src/message/history.rs +++ b/src/message/history.rs @@ -1,7 +1,7 @@ use itertools::Itertools as _; use super::{ - Id, Message, + Message, event::{Deleted, Event, Sent}, }; use crate::event::Sequence; @@ -13,10 +13,6 @@ pub struct History { // State interface impl History { - pub fn id(&self) -> &Id { - &self.message.id - } - // Snapshot of this message as it was when sent. (Note to the future: it's okay // if this returns a redacted or modified version of the message. If we // implement message editing by redacting the original body, then this should @@ -26,6 +22,15 @@ impl History { self.message.clone() } + pub fn as_of(&self, sequence: S) -> Option + where + S: Into, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() + } + // Snapshot of this message as of all events recorded in this history. pub fn as_snapshot(&self) -> Option { self.events().collect() diff --git a/src/message/repo.rs b/src/message/repo.rs index 2e9700a..83bf0d5 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -180,17 +180,15 @@ impl Messages<'_> { pub async fn delete( &mut self, - message: &History, + message: &Message, deleted: &Instant, ) -> Result { - let id = message.id(); - sqlx::query!( r#" insert into message_deleted (id, deleted_at, deleted_sequence) values ($1, $2, $3) "#, - id, + message.id, deleted.at, deleted.sequence, ) @@ -209,12 +207,12 @@ impl Messages<'_> { returning id as "id: Id" "#, deleted.sequence, - id, + message.id, ) .fetch_one(&mut *self.0) .await?; - let message = self.by_id(id).await?; + let message = self.by_id(&message.id).await?; Ok(message) } diff --git a/src/routes.rs b/src/routes.rs index 6993070..5b9e15a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,7 +4,7 @@ use axum::{ routing::{delete, get, post}, }; -use crate::{app::App, boot, conversation, event, expire, invite, message, setup, ui, user}; +use crate::{app::App, boot, conversation, event, expire, invite, login, message, setup, ui}; pub fn routes(app: &App) -> Router { // UI routes that can be accessed before the administrator completes setup. @@ -27,8 +27,8 @@ pub fn routes(app: &App) -> Router { // API routes that require the administrator to complete setup first. let api_setup_required = Router::new() - .route("/api/auth/login", post(user::handlers::login)) - .route("/api/auth/logout", post(user::handlers::logout)) + .route("/api/auth/login", post(login::handlers::login)) + .route("/api/auth/logout", post(login::handlers::logout)) .route("/api/boot", get(boot::handlers::boot)) .route("/api/conversations", post(conversation::handlers::create)) .route( @@ -44,7 +44,7 @@ pub fn routes(app: &App) -> Router { .route("/api/invite/{invite}", get(invite::handlers::get)) .route("/api/invite/{invite}", post(invite::handlers::accept)) .route("/api/messages/{message}", delete(message::handlers::delete)) - .route("/api/password", post(user::handlers::change_password)) + .route("/api/password", post(login::handlers::change_password)) // Run expiry whenever someone accesses the API. This was previously a blanket middleware // affecting the whole service, but loading the client makes a several requests before the // client can completely load, each of which was triggering expiry. There is absolutely no diff --git a/src/setup/app.rs b/src/setup/app.rs index c1c53c5..2a8ec30 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -35,9 +35,9 @@ impl<'a> Setup<'a> { Err(Error::SetupCompleted) } else { let stored = validated.store(&mut tx).await?; - let user = stored.user().as_created(); + let login = stored.login(); - let (token, secret) = Token::generate(&user, created_at); + let (token, secret) = Token::generate(login, created_at); tx.tokens().create(&token, &secret).await?; tx.commit().await?; diff --git a/src/test/fixtures/cookie.rs b/src/test/fixtures/cookie.rs index f5a32a6..7dc5083 100644 --- a/src/test/fixtures/cookie.rs +++ b/src/test/fixtures/cookie.rs @@ -1,11 +1,7 @@ use uuid::Uuid; use crate::{ - app::App, - clock::RequestedAt, - name::Name, - password::Password, - token::{Secret, extract::IdentityCookie}, + app::App, clock::RequestedAt, name::Name, password::Password, token::extract::IdentityCookie, }; pub fn not_logged_in() -> IdentityCookie { @@ -19,18 +15,14 @@ pub async fn logged_in( ) -> IdentityCookie { let (name, password) = credentials; let secret = app - .tokens() - .login(name, password, now) + .logins() + .with_password(name, password, now) .await .expect("should succeed given known-valid credentials"); IdentityCookie::new().set(secret) } -pub fn secret(identity: &IdentityCookie) -> Secret { - identity.secret().expect("identity contained a secret") -} - pub fn fictitious() -> IdentityCookie { let token = Uuid::new_v4().to_string(); IdentityCookie::new().set(token) diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index ac353de..93e4a38 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -37,8 +37,8 @@ pub async fn logged_in( } pub fn fictitious() -> Identity { - let user = fixtures::user::fictitious(); - let (token, _) = Token::generate(&user, &fixtures::now()); + let login = fixtures::login::fictitious(); + let (token, _) = Token::generate(&login, &fixtures::now()); - Identity { token, user } + Identity { token, login } } diff --git a/src/test/fixtures/invite.rs b/src/test/fixtures/invite.rs index 7a41eb6..654d1b4 100644 --- a/src/test/fixtures/invite.rs +++ b/src/test/fixtures/invite.rs @@ -2,10 +2,10 @@ use crate::{ app::App, clock::DateTime, invite::{self, Invite}, - user::User, + login::Login, }; -pub async fn issue(app: &App, issuer: &User, issued_at: &DateTime) -> Invite { +pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite { app.invites() .issue(issuer, issued_at) .await diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs new file mode 100644 index 0000000..d9aca81 --- /dev/null +++ b/src/test/fixtures/login.rs @@ -0,0 +1,21 @@ +use crate::{ + app::App, + clock::DateTime, + login::{self, Login}, + test::fixtures::user::{propose, propose_name}, +}; + +pub async fn create(app: &App, created_at: &DateTime) -> Login { + let (name, password) = propose(); + app.users() + .create(&name, &password, created_at) + .await + .expect("should always succeed if the user is actually new") +} + +pub fn fictitious() -> Login { + Login { + id: login::Id::generate(), + name: propose_name(), + } +} diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 03f8072..92ac1f5 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -4,14 +4,14 @@ use crate::{ app::App, clock::RequestedAt, conversation::Conversation, + login::Login, message::{self, Body, Message}, - user::User, }; pub async fn send( app: &App, conversation: &Conversation, - sender: &User, + sender: &Login, sent_at: &RequestedAt, ) -> Message { let body = propose(); diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 87d3fa1..3d69cfa 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -9,6 +9,7 @@ pub mod event; pub mod future; pub mod identity; pub mod invite; +pub mod login; pub mod message; pub mod user; diff --git a/src/test/fixtures/user.rs b/src/test/fixtures/user.rs index 086f866..d4d8db4 100644 --- a/src/test/fixtures/user.rs +++ b/src/test/fixtures/user.rs @@ -1,13 +1,7 @@ use faker_rand::{en_us::internet, lorem::Paragraphs}; use uuid::Uuid; -use crate::{ - app::App, - clock::RequestedAt, - name::Name, - password::Password, - user::{self, User}, -}; +use crate::{app::App, clock::RequestedAt, login::Login, name::Name, password::Password}; pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) { let (name, password) = propose(); @@ -20,19 +14,8 @@ pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, (user.name, password) } -pub async fn create(app: &App, created_at: &RequestedAt) -> User { - let (name, password) = propose(); - app.users() - .create(&name, &password, created_at) - .await - .expect("should always succeed if the login is actually new") -} - -pub fn fictitious() -> User { - User { - id: user::Id::generate(), - name: propose_name(), - } +pub async fn create(app: &App, created_at: &RequestedAt) -> Login { + super::login::create(app, created_at).await } pub fn propose() -> (Name, Password) { @@ -43,7 +26,7 @@ pub fn propose_invalid_name() -> Name { rand::random::().to_string().into() } -fn propose_name() -> Name { +pub(crate) fn propose_name() -> Name { rand::random::().to_string().into() } diff --git a/src/test/verify/identity.rs b/src/test/verify/identity.rs index 226ee74..8e2d36e 100644 --- a/src/test/verify/identity.rs +++ b/src/test/verify/identity.rs @@ -1,9 +1,9 @@ use crate::{ app::App, + login::Login, name::Name, test::{fixtures, verify}, token::{app::ValidateError, extract::IdentityCookie}, - user::User, }; pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) { @@ -13,11 +13,11 @@ pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) { verify::token::valid_for_name(app, &secret, name).await; } -pub async fn valid_for_user(app: &App, identity: &IdentityCookie, user: &User) { +pub async fn valid_for_login(app: &App, identity: &IdentityCookie, login: &Login) { let secret = identity .secret() .expect("identity cookie must be set to be valid"); - verify::token::valid_for_user(app, &secret, user).await; + verify::token::valid_for_login(app, &secret, login).await; } pub async fn invalid(app: &App, identity: &IdentityCookie) { diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs index 3f291a3..ae2e91e 100644 --- a/src/test/verify/login.rs +++ b/src/test/verify/login.rs @@ -1,15 +1,15 @@ use crate::{ app::App, + login::app::LoginError, name::Name, password::Password, test::{fixtures, verify}, - token::app::LoginError, }; pub async fn valid_login(app: &App, name: &Name, password: &Password) { let secret = app - .tokens() - .login(&name, &password, &fixtures::now()) + .logins() + .with_password(name, password, &fixtures::now()) .await .expect("login credentials expected to be valid"); verify::token::valid_for_name(&app, &secret, &name).await; @@ -17,8 +17,8 @@ pub async fn valid_login(app: &App, name: &Name, password: &Password) { pub async fn invalid_login(app: &App, name: &Name, password: &Password) { let error = app - .tokens() - .login(name, password, &fixtures::now()) + .logins() + .with_password(name, password, &fixtures::now()) .await .expect_err("login credentials expected not to be valid"); assert!(matches!(error, LoginError::Rejected)); diff --git a/src/test/verify/token.rs b/src/test/verify/token.rs index 99cd31f..adc4397 100644 --- a/src/test/verify/token.rs +++ b/src/test/verify/token.rs @@ -1,9 +1,9 @@ use crate::{ app::App, + login::Login, name::Name, test::fixtures, token::{Secret, app}, - user::User, }; pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) { @@ -12,16 +12,16 @@ pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) { .validate(secret, &fixtures::now()) .await .expect("provided secret is valid"); - assert_eq!(name, &identity.user.name); + assert_eq!(name, &identity.login.name); } -pub async fn valid_for_user(app: &App, secret: &Secret, user: &User) { +pub async fn valid_for_login(app: &App, secret: &Secret, login: &Login) { let identity = app .tokens() .validate(secret, &fixtures::now()) .await .expect("provided secret is valid"); - assert_eq!(user, &identity.user); + assert_eq!(login, &identity.login); } pub async fn invalid(app: &App, secret: &Secret) { diff --git a/src/token/app.rs b/src/token/app.rs index a7a843d..fb5d712 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -8,15 +8,9 @@ use sqlx::sqlite::SqlitePool; use super::{ Broadcaster, Event as TokenEvent, Secret, Token, extract::Identity, - repo::{self, Provider as _, auth::Provider as _}, -}; -use crate::{ - clock::DateTime, - db::NotFound as _, - name::{self, Name}, - password::Password, - user::{User, repo::Provider as _}, + repo::{self, Provider as _}, }; +use crate::{clock::DateTime, db::NotFound as _, name}; pub struct Tokens<'a> { db: &'a SqlitePool, @@ -28,100 +22,20 @@ impl<'a> Tokens<'a> { Self { db, token_events } } - pub async fn login( - &self, - name: &Name, - password: &Password, - login_at: &DateTime, - ) -> Result { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_name(name) - .await - .optional()? - .ok_or(LoginError::Rejected)?; - // Split the transaction here to avoid holding the tx open (potentially blocking - // other writes) while we do the fairly expensive task of verifying the - // password. It's okay if the token issuance transaction happens some notional - // amount of time after retrieving the login, as inserting the token will fail - // if the account is deleted during that time. - tx.commit().await?; - - let user = user.as_snapshot().ok_or(LoginError::Rejected)?; - - if stored_hash.verify(password)? { - let mut tx = self.db.begin().await?; - let (token, secret) = Token::generate(&user, login_at); - tx.tokens().create(&token, &secret).await?; - tx.commit().await?; - - Ok(secret) - } else { - Err(LoginError::Rejected) - } - } - - pub async fn change_password( - &self, - user: &User, - password: &Password, - to: &Password, - changed_at: &DateTime, - ) -> Result { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_user(user) - .await - .optional()? - .ok_or(LoginError::Rejected)?; - // Split the transaction here to avoid holding the tx open (potentially blocking - // other writes) while we do the fairly expensive task of verifying the - // password. It's okay if the token issuance transaction happens some notional - // amount of time after retrieving the login, as inserting the token will fail - // if the account is deleted during that time. - tx.commit().await?; - - if !stored_hash.verify(password)? { - return Err(LoginError::Rejected); - } - - let user_snapshot = user.as_snapshot().ok_or(LoginError::Rejected)?; - let to_hash = to.hash()?; - - let mut tx = self.db.begin().await?; - tx.users().set_password(&user, &to_hash).await?; - - let tokens = tx.tokens().revoke_all(&user).await?; - let (token, secret) = Token::generate(&user_snapshot, changed_at); - tx.tokens().create(&token, &secret).await?; - - tx.commit().await?; - - for event in tokens.into_iter().map(TokenEvent::Revoked) { - self.token_events.broadcast(event); - } - - Ok(secret) - } - pub async fn validate( &self, secret: &Secret, used_at: &DateTime, ) -> Result { let mut tx = self.db.begin().await?; - let (token, user) = tx + let (token, login) = tx .tokens() .validate(secret, used_at) .await .not_found(|| ValidateError::InvalidToken)?; tx.commit().await?; - let user = user.as_snapshot().ok_or(ValidateError::LoginDeleted)?; - - Ok(Identity { token, user }) + Ok(Identity { token, login }) } pub async fn limit_stream( @@ -207,34 +121,10 @@ impl<'a> Tokens<'a> { } } -#[derive(Debug, thiserror::Error)] -pub enum LoginError { - #[error("invalid login")] - Rejected, - #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] - Name(#[from] name::Error), - #[error(transparent)] - PasswordHash(#[from] password_hash::Error), -} - -impl From for LoginError { - fn from(error: repo::auth::LoadError) -> Self { - use repo::auth::LoadError; - match error { - LoadError::Database(error) => error.into(), - LoadError::Name(error) => error.into(), - } - } -} - #[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] InvalidToken, - #[error("user deleted")] - LoginDeleted, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index d01ab53..960fe60 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -10,14 +10,14 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, Unauthorized}, + login::Login, token::{Token, app::ValidateError}, - user::User, }; #[derive(Clone, Debug)] pub struct Identity { pub token: Token, - pub user: User, + pub login: Login, } impl FromRequestParts for Identity { diff --git a/src/token/mod.rs b/src/token/mod.rs index 58ff08b..b2dd6f1 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -8,30 +8,26 @@ mod secret; use uuid::Uuid; -use crate::{ - clock::DateTime, - user::{self, User}, -}; - pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret}; +use crate::{clock::DateTime, login, login::Login}; #[derive(Clone, Debug)] pub struct Token { pub id: Id, - pub user: user::Id, + pub login: login::Id, pub issued_at: DateTime, pub last_used_at: DateTime, } impl Token { - pub fn generate(user: &User, issued_at: &DateTime) -> (Self, Secret) { + pub fn generate(login: &Login, issued_at: &DateTime) -> (Self, Secret) { let id = Id::generate(); let secret = Uuid::new_v4().to_string().into(); ( Self { id, - user: user.id.clone(), + login: login.id.clone(), issued_at: *issued_at, last_used_at: *issued_at, }, diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs deleted file mode 100644 index a42fa1a..0000000 --- a/src/token/repo/auth.rs +++ /dev/null @@ -1,105 +0,0 @@ -use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; - -use crate::{ - clock::DateTime, - db::NotFound, - event::{Instant, Sequence}, - name::{self, Name}, - password::StoredHash, - user::{self, History, User}, -}; - -pub trait Provider { - fn auth(&mut self) -> Auth<'_>; -} - -impl Provider for Transaction<'_, Sqlite> { - fn auth(&mut self) -> Auth<'_> { - Auth(self) - } -} - -pub struct Auth<'t>(&'t mut SqliteConnection); - -impl Auth<'_> { - pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), LoadError> { - let name = name.canonical(); - let row = sqlx::query!( - r#" - select - id as "id: user::Id", - login.display_name as "display_name: String", - login.canonical_name as "canonical_name: String", - user.created_sequence as "created_sequence: Sequence", - user.created_at as "created_at: DateTime", - login.password as "password: StoredHash" - from user - join login using (id) - where login.canonical_name = $1 - "#, - name, - ) - .fetch_one(&mut *self.0) - .await?; - - let login = History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), - }; - - Ok((login, row.password)) - } - - pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> { - let row = sqlx::query!( - r#" - select - id as "id: user::Id", - login.display_name as "display_name: String", - login.canonical_name as "canonical_name: String", - user.created_sequence as "created_sequence: Sequence", - user.created_at as "created_at: DateTime", - login.password as "password: StoredHash" - from user - join login using (id) - where id = $1 - "#, - user.id, - ) - .fetch_one(&mut *self.0) - .await?; - - let user = History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), - }; - - Ok((user, row.password)) - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum LoadError { - Database(#[from] sqlx::Error), - Name(#[from] name::Error), -} - -impl NotFound for Result { - type Ok = T; - type Error = LoadError; - - fn optional(self) -> Result, LoadError> { - match self { - Ok(value) => Ok(Some(value)), - Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), - Err(other) => Err(other), - } - } -} diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs index d8463eb..9df5bbb 100644 --- a/src/token/repo/mod.rs +++ b/src/token/repo/mod.rs @@ -1,4 +1,3 @@ -pub mod auth; mod token; pub use self::token::{LoadError, Provider}; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index afcde53..52a3987 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -3,10 +3,9 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use crate::{ clock::DateTime, db::NotFound, - event::{Instant, Sequence}, + login::{self, Login}, name::{self, Name}, token::{Id, Secret, Token}, - user::{self, History, User}, }; pub trait Provider { @@ -31,11 +30,11 @@ impl Tokens<'_> { "#, token.id, secret, - token.user, + token.login, token.issued_at, token.last_used_at, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; Ok(()) @@ -71,8 +70,7 @@ impl Tokens<'_> { } // Revoke tokens for a login - pub async fn revoke_all(&mut self, user: &user::History) -> Result, sqlx::Error> { - let user = user.id(); + pub async fn revoke_all(&mut self, login: &Login) -> Result, sqlx::Error> { let tokens = sqlx::query_scalar!( r#" delete @@ -80,7 +78,7 @@ impl Tokens<'_> { where login = $1 returning id as "id: Id" "#, - user, + login.id, ) .fetch_all(&mut *self.0) .await?; @@ -106,14 +104,11 @@ impl Tokens<'_> { Ok(tokens) } - // Validate a token by its secret, retrieving the associated Login record. - // Will return an error if the token is not valid. If successful, the - // retrieved token's last-used timestamp will be set to `used_at`. pub async fn validate( &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Token, History), LoadError> { + ) -> Result<(Token, Login), LoadError> { // I would use `update … returning` to do this in one query, but // sqlite3, as of this writing, does not allow an update's `returning` // clause to reference columns from tables joined into the update. Two @@ -125,7 +120,7 @@ impl Tokens<'_> { where secret = $2 returning id as "id: Id", - login as "login: user::Id", + login as "login: login::Id", issued_at as "issued_at: DateTime", last_used_at as "last_used_at: DateTime" "#, @@ -134,7 +129,7 @@ impl Tokens<'_> { ) .map(|row| Token { id: row.id, - user: row.login, + login: row.login, issued_at: row.issued_at, last_used_at: row.last_used_at, }) @@ -144,24 +139,18 @@ impl Tokens<'_> { let user = sqlx::query!( r#" select - id as "id: user::Id", - login.display_name as "display_name: String", - login.canonical_name as "canonical_name: String", - user.created_sequence as "created_sequence: Sequence", - user.created_at as "created_at: DateTime" - from user - join login using (id) + id as "id: login::Id", + display_name, + canonical_name + from login where id = $1 "#, - token.user, + token.login, ) .map(|row| { - Ok::<_, name::Error>(History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), + Ok::<_, name::Error>(Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, }) }) .fetch_one(&mut *self.0) diff --git a/src/user/app.rs b/src/user/app.rs index 301c39c..0d6046c 100644 --- a/src/user/app.rs +++ b/src/user/app.rs @@ -1,10 +1,7 @@ use sqlx::sqlite::SqlitePool; -use super::{ - User, - create::{self, Create}, -}; -use crate::{clock::DateTime, event::Broadcaster, name::Name, password::Password}; +use super::create::{self, Create}; +use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password}; pub struct Users<'a> { db: &'a SqlitePool, @@ -21,7 +18,7 @@ impl<'a> Users<'a> { name: &Name, password: &Password, created_at: &DateTime, - ) -> Result { + ) -> Result { let create = Create::begin(name, password, created_at); let validated = create.validate()?; @@ -29,10 +26,10 @@ impl<'a> Users<'a> { let stored = validated.store(&mut tx).await?; tx.commit().await?; - let user = stored.user().to_owned(); + let login = stored.login().to_owned(); stored.publish(self.events); - Ok(user.as_created()) + Ok(login) } } @@ -46,7 +43,6 @@ pub enum CreateError { Database(#[from] sqlx::Error), } -#[cfg(test)] impl From for CreateError { fn from(error: create::Error) -> Self { match error { diff --git a/src/user/create.rs b/src/user/create.rs index 5d7bf65..5c060c9 100644 --- a/src/user/create.rs +++ b/src/user/create.rs @@ -4,6 +4,7 @@ use super::{History, repo::Provider as _, validate}; use crate::{ clock::DateTime, event::{Broadcaster, Event, repo::Provider as _}, + login::{self, Login, repo::Provider as _}, name::Name, password::{Password, StoredHash}, }; @@ -39,7 +40,7 @@ impl<'a> Create<'a> { Ok(Validated { name, - password_hash, + password: password_hash, created_at, }) } @@ -48,7 +49,7 @@ impl<'a> Create<'a> { #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Validated<'a> { name: &'a Name, - password_hash: StoredHash, + password: StoredHash, created_at: &'a DateTime, } @@ -56,31 +57,38 @@ impl Validated<'_> { pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result { let Self { name, - password_hash, + password, created_at, } = self; + let login = Login { + id: login::Id::generate(), + name: name.to_owned(), + }; + let created = tx.sequence().next(created_at).await?; - let user = tx.users().create(name, &password_hash, &created).await?; + tx.logins().create(&login, &password).await?; + let user = tx.users().create(&login, &created).await?; - Ok(Stored { user }) + Ok(Stored { user, login }) } } #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Stored { user: History, + login: Login, } impl Stored { - pub fn publish(self, events: &Broadcaster) { - let Self { user } = self; + pub fn publish(self, broadcaster: &Broadcaster) { + let Self { user, login: _ } = self; - events.broadcast(user.events().map(Event::from).collect::>()); + broadcaster.broadcast(user.events().map(Event::from).collect::>()); } - pub fn user(&self) -> &History { - &self.user + pub fn login(&self) -> &Login { + &self.login } } diff --git a/src/user/handlers/login/mod.rs b/src/user/handlers/login/mod.rs deleted file mode 100644 index d3e0e8c..0000000 --- a/src/user/handlers/login/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::Internal, - name::Name, - password::Password, - token::{app, extract::IdentityCookie}, -}; - -#[cfg(test)] -mod test; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .tokens() - .login(&request.name, &request.password, &now) - .await - .map_err(Error)?; - let identity = identity.set(secret); - Ok((identity, Empty)) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub name: Name, - pub password: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::LoginError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - // not error::Unauthorized due to differing messaging - (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/user/handlers/login/test.rs b/src/user/handlers/login/test.rs deleted file mode 100644 index 56fc2c4..0000000 --- a/src/user/handlers/login/test.rs +++ /dev/null @@ -1,110 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::{fixtures, verify}, - token::app, -}; - -#[tokio::test] -async fn correct_credentials() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let logged_in_at = fixtures::now(); - let request = super::Request { - name: name.clone(), - password, - }; - let (identity, Empty) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - - // Verify the return value's basic structure - - verify::identity::valid_for_name(&app, &identity, &name).await; -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let logged_in_at = fixtures::now(); - let (name, password) = fixtures::user::propose(); - let request = super::Request { - name: name.clone(), - password, - }; - let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password fails"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn incorrect_password() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::user::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::now(); - let identity = fixtures::cookie::not_logged_in(); - let request = super::Request { - name: login.name, - password: fixtures::user::propose_password(), - }; - let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn token_expires() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::ancient(); - let identity = fixtures::cookie::not_logged_in(); - let request = super::Request { name, password }; - let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let expired_at = fixtures::now(); - app.tokens() - .expire(&expired_at) - .await - .expect("expiring tokens never fails"); - - verify::token::invalid(&app, &secret).await; -} diff --git a/src/user/handlers/logout/mod.rs b/src/user/handlers/logout/mod.rs deleted file mode 100644 index f759451..0000000 --- a/src/user/handlers/logout/mod.rs +++ /dev/null @@ -1,53 +0,0 @@ -use axum::{ - extract::{Json, State}, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::{Internal, Unauthorized}, - token::{app, extract::IdentityCookie}, -}; - -#[cfg(test)] -mod test; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(_): Json, -) -> Result<(IdentityCookie, Empty), Error> { - if let Some(secret) = identity.secret() { - let validated_ident = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&validated_ident.token).await?; - } - - let identity = identity.clear(); - Ok((identity, Empty)) -} - -// This forces the only valid request to be `{}`, and not the infinite -// variation allowed when there's no body extractor. -#[derive(Default, serde::Deserialize)] -pub struct Request {} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::ValidateError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => { - Unauthorized.into_response() - } - app::ValidateError::Name(_) | app::ValidateError::Database(_) => { - Internal::from(error).into_response() - } - } - } -} diff --git a/src/user/handlers/logout/test.rs b/src/user/handlers/logout/test.rs deleted file mode 100644 index 8ad4853..0000000 --- a/src/user/handlers/logout/test.rs +++ /dev/null @@ -1,72 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::{fixtures, verify}, - token::app, -}; - -#[tokio::test] -async fn successful() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let now = fixtures::now(); - let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; - let secret = fixtures::cookie::secret(&identity); - - // Call the endpoint - - let (response_identity, Empty) = super::handler( - State(app.clone()), - fixtures::now(), - identity.clone(), - Json::default(), - ) - .await - .expect("logged out with a valid token"); - - // Verify the return value's basic structure - assert!(response_identity.secret().is_none()); - - // Verify the semantics - verify::token::invalid(&app, &secret).await; -} - -#[tokio::test] -async fn no_identity() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let (identity, Empty) = super::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect("logged out with no token succeeds"); - - // Verify the return value's basic structure - - assert!(identity.secret().is_none()); -} - -#[tokio::test] -async fn invalid_token() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::fictitious(); - let super::Error(error) = - super::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect_err("logged out with an invalid token fails"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::ValidateError::InvalidToken)); -} diff --git a/src/user/handlers/mod.rs b/src/user/handlers/mod.rs deleted file mode 100644 index 5cadbb5..0000000 --- a/src/user/handlers/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod login; -mod logout; -mod password; - -pub use login::handler as login; -pub use logout::handler as logout; -pub use password::handler as change_password; diff --git a/src/user/handlers/password/mod.rs b/src/user/handlers/password/mod.rs deleted file mode 100644 index 5e69c1c..0000000 --- a/src/user/handlers/password/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - empty::Empty, - error::Internal, - password::Password, - token::{ - app, - extract::{Identity, IdentityCookie}, - }, -}; - -#[cfg(test)] -mod test; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: Identity, - cookie: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .tokens() - .change_password(&identity.user, &request.password, &request.to, &now) - .await - .map_err(Error)?; - let cookie = cookie.set(secret); - Ok((cookie, Empty)) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub password: Password, - pub to: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::LoginError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - (StatusCode::BAD_REQUEST, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/user/handlers/password/test.rs b/src/user/handlers/password/test.rs deleted file mode 100644 index 81020a1..0000000 --- a/src/user/handlers/password/test.rs +++ /dev/null @@ -1,47 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - empty::Empty, - test::{fixtures, verify}, -}; - -#[tokio::test] -async fn password_change() { - // Set up the environment - let app = fixtures::scratch_app().await; - let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; - let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; - - // Call the endpoint - let (name, password) = creds; - let to = fixtures::user::propose_password(); - let request = super::Request { - password: password.clone(), - to: to.clone(), - }; - let (new_cookie, Empty) = super::handler( - State(app.clone()), - fixtures::now(), - identity.clone(), - cookie.clone(), - Json(request), - ) - .await - .expect("changing passwords succeeds"); - - // Verify that we have a new session - assert_ne!(cookie.secret(), new_cookie.secret()); - - // Verify that we're still ourselves - verify::identity::valid_for_user(&app, &new_cookie, &identity.user).await; - - // Verify that our original token is no longer valid - verify::identity::invalid(&app, &cookie).await; - - // Verify that our original password is no longer valid - verify::login::invalid_login(&app, &name, &password).await; - - // Verify that our new password is valid - verify::login::valid_login(&app, &name, &to).await; -} diff --git a/src/user/history.rs b/src/user/history.rs index 72e0aee..f58e9c7 100644 --- a/src/user/history.rs +++ b/src/user/history.rs @@ -1,8 +1,8 @@ use super::{ - Id, User, + User, event::{Created, Event}, }; -use crate::event::Instant; +use crate::event::{Instant, Sequence}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct History { @@ -12,21 +12,13 @@ pub struct History { // State interface impl History { - pub fn id(&self) -> &Id { - &self.user.id - } - - // Snapshot of this user as it was when created. (Note to the future: it's okay - // if this returns a redacted or modified version of the user. If we implement - // renames by redacting the original name, then this should return the edited - // user, not the original, even if that's not how it was "as created.") - pub fn as_created(&self) -> User { - self.user.clone() - } - - // Snapshot of this user, as of all events recorded in this history. - pub fn as_snapshot(&self) -> Option { - self.events().collect() + pub fn as_of(&self, sequence: S) -> Option + where + S: Into, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() } } diff --git a/src/user/id.rs b/src/user/id.rs index 3ad8d16..ceb310a 100644 --- a/src/user/id.rs +++ b/src/user/id.rs @@ -1,7 +1,24 @@ +use crate::login; + // Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L` // prefix, instead. pub type Id = crate::id::Id; +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From for Id { + fn from(login: login::Id) -> Self { + Self::from(String::from(login)) + } +} + +impl PartialEq for Id { + fn eq(&self, other: &login::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct User; diff --git a/src/user/mod.rs b/src/user/mod.rs index 60ec209..95bec2f 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -2,9 +2,8 @@ pub mod app; pub mod create; pub mod event; -pub mod handlers; mod history; -mod id; +pub mod id; pub mod repo; mod snapshot; mod validate; diff --git a/src/user/repo.rs b/src/user/repo.rs index bfb603d..aaf3b73 100644 --- a/src/user/repo.rs +++ b/src/user/repo.rs @@ -3,9 +3,10 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use crate::{ clock::DateTime, + db::NotFound, event::{Instant, Sequence}, + login::Login, name::{self, Name}, - password::StoredHash, user::{History, Id, User}, }; @@ -24,71 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection); impl Users<'_> { pub async fn create( &mut self, - name: &Name, - password: &StoredHash, + login: &Login, created: &Instant, ) -> Result { - let id = Id::generate(); - let display_name = name.display(); - let canonical_name = name.canonical(); - - sqlx::query!( - r#" - insert into login (id, display_name, canonical_name, password) - values ($1, $2, $3, $4) - "#, - id, - display_name, - canonical_name, - password, - ) - .execute(&mut *self.0) - .await?; - sqlx::query!( r#" insert into user (id, created_sequence, created_at) values ($1, $2, $3) "#, - id, + login.id, created.sequence, created.at, ) .execute(&mut *self.0) .await?; - let user = History { - created: *created, + Ok(History { user: User { - id, - name: name.clone(), + id: login.id.clone().into(), + name: login.name.clone(), }, - }; - - Ok(user) + created: *created, + }) } - pub async fn set_password( - &mut self, - login: &History, - to: &StoredHash, - ) -> Result<(), sqlx::Error> { - let login = login.id(); - - sqlx::query_scalar!( + pub async fn by_login(&mut self, login: &Login) -> Result { + let user = sqlx::query!( r#" - update login - set password = $1 - where id = $2 - returning id as "id: Id" + select + id as "id: Id", + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_at as "created_at: DateTime", + user.created_sequence as "created_sequence: Sequence" + from user + join login using (id) + where id = $1 "#, - to, - login, + login.id, ) + .map(|row| { + Ok::<_, LoadError>(History { + user: User { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) + }) .fetch_one(&mut *self.0) - .await?; + .await??; - Ok(()) + Ok(user) } pub async fn all(&mut self, resume_at: Sequence) -> Result, LoadError> { @@ -163,3 +151,16 @@ pub enum LoadError { Database(#[from] sqlx::Error), Name(#[from] name::Error), } + +impl NotFound for Result { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index 42b86f0..c415d0c 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -62,9 +62,9 @@ class Session { ), ); - static boot({ user, resume_point, heartbeat, events }) { + static boot({ login, resume_point, heartbeat, events }) { const remote = r.State.boot({ - currentUser: user, + currentUser: login, resumePoint: resume_point, heartbeat, events, @@ -73,9 +73,9 @@ class Session { return new Session(remote, local); } - reboot({ user, resume_point, heartbeat, events }) { + reboot({ login, resume_point, heartbeat, events }) { this.remote = r.State.boot({ - currentUser: user, + currentUser: login, resumePoint: resume_point, heartbeat, events, -- cgit v1.2.3