summaryrefslogtreecommitdiff
path: root/src/login
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-10-29 20:26:47 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-10-29 20:33:42 -0400
commit9ae0faf4f027caaaf3bc4a42738d4ed31e67852d (patch)
tree69c61f71f38a1e13012f0e7fbd789c6f7bd013ca /src/login
parentda485e523913df28def6335be0836b1fc437617f (diff)
Create a dedicated workflow type for creating logins.
Nasty design corner. Logins need to be created in three places: 1. In tests, using app.logins().create(…); 2. On initial setup, using app.setup().initial(…); and 3. When accepting invites, using app.invites().accept(…). These three places do the same thing with respect to logins, but also do a varying mix of other things. Testing is the simplest and _only_ creates a login. Initial setup and invite acceptance both issue a token for the newly-created login. Accepting an invite also invalidates the invite. Previously, those three functions have been copy-pasted variations on a theme. Now that we have validation, the copy-paste approach is no longer tenable; it will become increasingly hard to ensure that the three functions (plus any future functions) remain in synch. To accommodate the variations while consolidating login creation, I've added a typestate-based state machine, which is driven by method calls: * A creation attempt begins with `let create = Create::begin()`. This always succeeds; it packages up arguments used in later steps, but does nothing else. * A creation attempt can be validated using `let validated = create.validate()?`. This may fail. Input validation and password hashing are carried out at this stage, making it potentially expensive. * A validated attempt can be stored in the DB, using `let stored = validated.store(&mut tx).await?`. This may fail. The login will be written to the DB; the caller is responsible for transaction demarcation, to allow other things to take place in the same transaction. * A fully-stored attempt can be used to publish events, using `let login = stored.publish(self.events)`. This always succeeds, and unwraps the state machine to its final product (a `login::History`).
Diffstat (limited to 'src/login')
-rw-r--r--src/login/app.rs38
-rw-r--r--src/login/create.rs95
-rw-r--r--src/login/mod.rs3
3 files changed, 118 insertions, 18 deletions
diff --git a/src/login/app.rs b/src/login/app.rs
index c1bfe6e..6da26e9 100644
--- a/src/login/app.rs
+++ b/src/login/app.rs
@@ -3,13 +3,12 @@ use sqlx::sqlite::SqlitePool;
use super::repo::Provider as _;
#[cfg(test)]
-use super::{validate, Login, Password};
-#[cfg(test)]
-use crate::{
- clock::DateTime,
- event::{repo::Provider as _, Broadcaster, Event},
- name::Name,
+use super::{
+ create::{self, Create},
+ Login, Password,
};
+#[cfg(test)]
+use crate::{clock::DateTime, event::Broadcaster, name::Name};
pub struct Logins<'a> {
db: &'a SqlitePool,
@@ -35,19 +34,14 @@ impl<'a> Logins<'a> {
password: &Password,
created_at: &DateTime,
) -> Result<Login, CreateError> {
- if !validate::name(name) {
- return Err(CreateError::InvalidName(name.clone()));
- }
-
- let password_hash = password.hash()?;
+ let create = Create::begin(name, password, created_at);
+ let validated = create.validate()?;
let mut tx = self.db.begin().await?;
- let created = tx.sequence().next(created_at).await?;
- let login = tx.logins().create(name, &password_hash, &created).await?;
+ let stored = validated.store(&mut tx).await?;
tx.commit().await?;
- self.events
- .broadcast(login.events().map(Event::from).collect::<Vec<_>>());
+ let login = stored.publish(self.events);
Ok(login.as_created())
}
@@ -67,7 +61,17 @@ pub enum CreateError {
#[error("invalid login name: {0}")]
InvalidName(Name),
#[error(transparent)]
- Database(#[from] sqlx::Error),
- #[error(transparent)]
PasswordHash(#[from] password_hash::Error),
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+}
+
+#[cfg(test)]
+impl From<create::Error> for CreateError {
+ fn from(error: create::Error) -> Self {
+ match error {
+ create::Error::InvalidName(name) => Self::InvalidName(name),
+ create::Error::PasswordHash(error) => Self::PasswordHash(error),
+ }
+ }
}
diff --git a/src/login/create.rs b/src/login/create.rs
new file mode 100644
index 0000000..693daaf
--- /dev/null
+++ b/src/login/create.rs
@@ -0,0 +1,95 @@
+use sqlx::{sqlite::Sqlite, Transaction};
+
+use super::{password::StoredHash, repo::Provider as _, validate, History, Password};
+use crate::{
+ clock::DateTime,
+ event::{repo::Provider as _, Broadcaster, Event},
+ name::Name,
+};
+
+pub struct Create<'a> {
+ name: &'a Name,
+ password: &'a Password,
+ created_at: &'a DateTime,
+}
+
+impl<'a> Create<'a> {
+ #[must_use = "dropping a login creation attempt is likely a mistake"]
+ pub fn begin(name: &'a Name, password: &'a Password, created_at: &'a DateTime) -> Self {
+ Self {
+ name,
+ password,
+ created_at,
+ }
+ }
+
+ #[must_use = "dropping a login creation attempt is likely a mistake"]
+ pub fn validate(self) -> Result<Validated<'a>, Error> {
+ let Self {
+ name,
+ password,
+ created_at,
+ } = self;
+
+ if !validate::name(name) {
+ return Err(Error::InvalidName(name.clone()));
+ }
+
+ let password_hash = password.hash()?;
+
+ Ok(Validated {
+ name,
+ password_hash,
+ created_at,
+ })
+ }
+}
+
+pub struct Validated<'a> {
+ name: &'a Name,
+ password_hash: StoredHash,
+ created_at: &'a DateTime,
+}
+
+impl<'a> Validated<'a> {
+ #[must_use = "dropping a login creation attempt is likely a mistake"]
+ pub async fn store<'c>(self, tx: &mut Transaction<'c, Sqlite>) -> Result<Stored, sqlx::Error> {
+ let Self {
+ name,
+ password_hash,
+ created_at,
+ } = self;
+
+ let created = tx.sequence().next(created_at).await?;
+ let login = tx.logins().create(name, &password_hash, &created).await?;
+
+ Ok(Stored { login })
+ }
+}
+
+pub struct Stored {
+ login: History,
+}
+
+impl Stored {
+ #[must_use = "dropping a login creation attempt is likely a mistake"]
+ pub fn publish(self, events: &Broadcaster) -> History {
+ let Self { login } = self;
+
+ events.broadcast(login.events().map(Event::from).collect::<Vec<_>>());
+
+ login
+ }
+
+ pub fn login(&self) -> &History {
+ &self.login
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("invalid login name: {0}")]
+ InvalidName(Name),
+ #[error(transparent)]
+ PasswordHash(#[from] password_hash::Error),
+}
diff --git a/src/login/mod.rs b/src/login/mod.rs
index 6d10e17..5a6d715 100644
--- a/src/login/mod.rs
+++ b/src/login/mod.rs
@@ -1,4 +1,5 @@
pub mod app;
+pub mod create;
pub mod event;
mod history;
mod id;
@@ -6,7 +7,7 @@ pub mod password;
pub mod repo;
mod routes;
mod snapshot;
-pub mod validate;
+mod validate;
pub use self::{
event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login,