diff options
Diffstat (limited to 'src/user')
| -rw-r--r-- | src/user/app.rs | 14 | ||||
| -rw-r--r-- | src/user/create.rs | 28 | ||||
| -rw-r--r-- | src/user/handlers/login/mod.rs | 56 | ||||
| -rw-r--r-- | src/user/handlers/login/test.rs | 110 | ||||
| -rw-r--r-- | src/user/handlers/logout/mod.rs | 53 | ||||
| -rw-r--r-- | src/user/handlers/logout/test.rs | 72 | ||||
| -rw-r--r-- | src/user/handlers/mod.rs | 7 | ||||
| -rw-r--r-- | src/user/handlers/password/mod.rs | 58 | ||||
| -rw-r--r-- | src/user/handlers/password/test.rs | 47 | ||||
| -rw-r--r-- | src/user/history.rs | 26 | ||||
| -rw-r--r-- | src/user/id.rs | 17 | ||||
| -rw-r--r-- | src/user/mod.rs | 3 | ||||
| -rw-r--r-- | src/user/repo.rs | 89 |
13 files changed, 95 insertions, 485 deletions
diff --git a/src/user/app.rs b/src/user/app.rs index 301c39c..0d6046c 100644 --- a/src/user/app.rs +++ b/src/user/app.rs @@ -1,10 +1,7 @@ use sqlx::sqlite::SqlitePool; -use super::{ - User, - create::{self, Create}, -}; -use crate::{clock::DateTime, event::Broadcaster, name::Name, password::Password}; +use super::create::{self, Create}; +use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password}; pub struct Users<'a> { db: &'a SqlitePool, @@ -21,7 +18,7 @@ impl<'a> Users<'a> { name: &Name, password: &Password, created_at: &DateTime, - ) -> Result<User, CreateError> { + ) -> Result<Login, CreateError> { let create = Create::begin(name, password, created_at); let validated = create.validate()?; @@ -29,10 +26,10 @@ impl<'a> Users<'a> { let stored = validated.store(&mut tx).await?; tx.commit().await?; - let user = stored.user().to_owned(); + let login = stored.login().to_owned(); stored.publish(self.events); - Ok(user.as_created()) + Ok(login) } } @@ -46,7 +43,6 @@ pub enum CreateError { Database(#[from] sqlx::Error), } -#[cfg(test)] impl From<create::Error> for CreateError { fn from(error: create::Error) -> Self { match error { diff --git a/src/user/create.rs b/src/user/create.rs index 5d7bf65..5c060c9 100644 --- a/src/user/create.rs +++ b/src/user/create.rs @@ -4,6 +4,7 @@ use super::{History, repo::Provider as _, validate}; use crate::{ clock::DateTime, event::{Broadcaster, Event, repo::Provider as _}, + login::{self, Login, repo::Provider as _}, name::Name, password::{Password, StoredHash}, }; @@ -39,7 +40,7 @@ impl<'a> Create<'a> { Ok(Validated { name, - password_hash, + password: password_hash, created_at, }) } @@ -48,7 +49,7 @@ impl<'a> Create<'a> { #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Validated<'a> { name: &'a Name, - password_hash: StoredHash, + password: StoredHash, created_at: &'a DateTime, } @@ -56,31 +57,38 @@ impl Validated<'_> { pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result<Stored, sqlx::Error> { let Self { name, - password_hash, + password, created_at, } = self; + let login = Login { + id: login::Id::generate(), + name: name.to_owned(), + }; + let created = tx.sequence().next(created_at).await?; - let user = tx.users().create(name, &password_hash, &created).await?; + tx.logins().create(&login, &password).await?; + let user = tx.users().create(&login, &created).await?; - Ok(Stored { user }) + Ok(Stored { user, login }) } } #[must_use = "dropping a user creation attempt is likely a mistake"] pub struct Stored { user: History, + login: Login, } impl Stored { - pub fn publish(self, events: &Broadcaster) { - let Self { user } = self; + pub fn publish(self, broadcaster: &Broadcaster) { + let Self { user, login: _ } = self; - events.broadcast(user.events().map(Event::from).collect::<Vec<_>>()); + broadcaster.broadcast(user.events().map(Event::from).collect::<Vec<_>>()); } - pub fn user(&self) -> &History { - &self.user + pub fn login(&self) -> &Login { + &self.login } } diff --git a/src/user/handlers/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<App>, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(request): Json<Request>, -) -> 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<App>, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(_): Json<Request>, -) -> Result<(IdentityCookie, Empty), Error> { - if let Some(secret) = identity.secret() { - let validated_ident = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&validated_ident.token).await?; - } - - let identity = 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<App>, - RequestedAt(now): RequestedAt, - identity: Identity, - cookie: IdentityCookie, - Json(request): Json<Request>, -) -> 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<User> { - self.events().collect() + pub fn as_of<S>(&self, sequence: S) -> Option<User> + where + S: Into<Sequence>, + { + self.events() + .filter(Sequence::up_to(sequence.into())) + .collect() } } diff --git a/src/user/id.rs b/src/user/id.rs index 3ad8d16..ceb310a 100644 --- a/src/user/id.rs +++ b/src/user/id.rs @@ -1,7 +1,24 @@ +use crate::login; + // Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L` // prefix, instead. pub type Id = crate::id::Id<User>; +// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but +// in practice a login and its associated user _must_ have IDs that encode to the same value. The +// two ID types are made interconvertible (via `From`) for this purpose. +impl From<login::Id> for Id { + fn from(login: login::Id) -> Self { + Self::from(String::from(login)) + } +} + +impl PartialEq<login::Id> for Id { + fn eq(&self, other: &login::Id) -> bool { + self.as_str().eq(other.as_str()) + } +} + #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct User; diff --git a/src/user/mod.rs b/src/user/mod.rs index 60ec209..95bec2f 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -2,9 +2,8 @@ pub mod app; pub mod create; pub mod event; -pub mod handlers; mod history; -mod id; +pub mod id; pub mod repo; mod snapshot; mod validate; diff --git a/src/user/repo.rs b/src/user/repo.rs index bfb603d..aaf3b73 100644 --- a/src/user/repo.rs +++ b/src/user/repo.rs @@ -3,9 +3,10 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use crate::{ clock::DateTime, + db::NotFound, event::{Instant, Sequence}, + login::Login, name::{self, Name}, - password::StoredHash, user::{History, Id, User}, }; @@ -24,71 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection); impl Users<'_> { pub async fn create( &mut self, - name: &Name, - password: &StoredHash, + login: &Login, created: &Instant, ) -> Result<History, sqlx::Error> { - let id = Id::generate(); - let display_name = name.display(); - let canonical_name = name.canonical(); - - sqlx::query!( - r#" - insert into login (id, display_name, canonical_name, password) - values ($1, $2, $3, $4) - "#, - id, - display_name, - canonical_name, - password, - ) - .execute(&mut *self.0) - .await?; - sqlx::query!( r#" insert into user (id, created_sequence, created_at) values ($1, $2, $3) "#, - id, + login.id, created.sequence, created.at, ) .execute(&mut *self.0) .await?; - let user = History { - created: *created, + Ok(History { user: User { - id, - name: name.clone(), + id: login.id.clone().into(), + name: login.name.clone(), }, - }; - - Ok(user) + created: *created, + }) } - pub async fn set_password( - &mut self, - login: &History, - to: &StoredHash, - ) -> Result<(), sqlx::Error> { - let login = login.id(); - - sqlx::query_scalar!( + pub async fn by_login(&mut self, login: &Login) -> Result<History, LoadError> { + let user = sqlx::query!( r#" - update login - set password = $1 - where id = $2 - returning id as "id: Id" + select + id as "id: Id", + login.display_name as "display_name: String", + login.canonical_name as "canonical_name: String", + user.created_at as "created_at: DateTime", + user.created_sequence as "created_sequence: Sequence" + from user + join login using (id) + where id = $1 "#, - to, - login, + login.id, ) + .map(|row| { + Ok::<_, LoadError>(History { + user: User { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) + }) .fetch_one(&mut *self.0) - .await?; + .await??; - Ok(()) + Ok(user) } pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> { @@ -163,3 +151,16 @@ pub enum LoadError { Database(#[from] sqlx::Error), Name(#[from] name::Error), } + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} |
