summaryrefslogtreecommitdiff
path: root/src/user
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-03-23 15:58:33 -0400
committerOwen Jacobson <owen@grimoire.ca>2025-03-23 16:25:22 -0400
commit2420f1e75d54a5f209b0267715f078a369d81eb1 (patch)
tree20edd531a3f2f765a23fef8e7a508c91bc7dc294 /src/user
parent7e15690d54ff849596401b43d163df9353062850 (diff)
Rename the `login` module to `user`.
Diffstat (limited to 'src/user')
-rw-r--r--src/user/app.rs56
-rw-r--r--src/user/create.rs95
-rw-r--r--src/user/event.rs36
-rw-r--r--src/user/history.rs52
-rw-r--r--src/user/id.rs24
-rw-r--r--src/user/mod.rs15
-rw-r--r--src/user/password.rs65
-rw-r--r--src/user/repo.rs153
-rw-r--r--src/user/routes/login/mod.rs4
-rw-r--r--src/user/routes/login/post.rs52
-rw-r--r--src/user/routes/login/test.rs128
-rw-r--r--src/user/routes/logout/mod.rs4
-rw-r--r--src/user/routes/logout/post.rs47
-rw-r--r--src/user/routes/logout/test.rs79
-rw-r--r--src/user/routes/mod.rs14
-rw-r--r--src/user/routes/password/mod.rs4
-rw-r--r--src/user/routes/password/post.rs54
-rw-r--r--src/user/routes/password/test.rs68
-rw-r--r--src/user/snapshot.rs52
-rw-r--r--src/user/validate.rs23
20 files changed, 1025 insertions, 0 deletions
diff --git a/src/user/app.rs b/src/user/app.rs
new file mode 100644
index 0000000..2ab356f
--- /dev/null
+++ b/src/user/app.rs
@@ -0,0 +1,56 @@
+use sqlx::sqlite::SqlitePool;
+
+use super::{
+ Password, User,
+ create::{self, Create},
+};
+use crate::{clock::DateTime, event::Broadcaster, name::Name};
+
+pub struct Users<'a> {
+ db: &'a SqlitePool,
+ events: &'a Broadcaster,
+}
+
+impl<'a> Users<'a> {
+ pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self {
+ Self { db, events }
+ }
+
+ pub async fn create(
+ &self,
+ name: &Name,
+ password: &Password,
+ created_at: &DateTime,
+ ) -> Result<User, CreateError> {
+ let create = Create::begin(name, password, created_at);
+ let validated = create.validate()?;
+
+ let mut tx = self.db.begin().await?;
+ let stored = validated.store(&mut tx).await?;
+ tx.commit().await?;
+
+ let user = stored.publish(self.events);
+
+ Ok(user.as_created())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum CreateError {
+ #[error("invalid user name: {0}")]
+ InvalidName(Name),
+ #[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/user/create.rs b/src/user/create.rs
new file mode 100644
index 0000000..da94685
--- /dev/null
+++ b/src/user/create.rs
@@ -0,0 +1,95 @@
+use sqlx::{Transaction, sqlite::Sqlite};
+
+use super::{History, Password, password::StoredHash, repo::Provider as _, validate};
+use crate::{
+ clock::DateTime,
+ event::{Broadcaster, Event, repo::Provider as _},
+ name::Name,
+};
+
+#[must_use = "dropping a user creation attempt is likely a mistake"]
+pub struct Create<'a> {
+ name: &'a Name,
+ password: &'a Password,
+ created_at: &'a DateTime,
+}
+
+impl<'a> Create<'a> {
+ pub fn begin(name: &'a Name, password: &'a Password, created_at: &'a DateTime) -> Self {
+ Self {
+ name,
+ password,
+ created_at,
+ }
+ }
+
+ 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,
+ })
+ }
+}
+
+#[must_use = "dropping a user creation attempt is likely a mistake"]
+pub struct Validated<'a> {
+ name: &'a Name,
+ password_hash: StoredHash,
+ created_at: &'a DateTime,
+}
+
+impl Validated<'_> {
+ pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result<Stored, sqlx::Error> {
+ let Self {
+ name,
+ password_hash,
+ created_at,
+ } = self;
+
+ let created = tx.sequence().next(created_at).await?;
+ let user = tx.users().create(name, &password_hash, &created).await?;
+
+ Ok(Stored { user })
+ }
+}
+
+#[must_use = "dropping a user creation attempt is likely a mistake"]
+pub struct Stored {
+ user: History,
+}
+
+impl Stored {
+ #[must_use = "dropping a user creation attempt is likely a mistake"]
+ pub fn publish(self, events: &Broadcaster) -> History {
+ let Self { user } = self;
+
+ events.broadcast(user.events().map(Event::from).collect::<Vec<_>>());
+
+ user
+ }
+
+ pub fn user(&self) -> &History {
+ &self.user
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("invalid user name: {0}")]
+ InvalidName(Name),
+ #[error(transparent)]
+ PasswordHash(#[from] password_hash::Error),
+}
diff --git a/src/user/event.rs b/src/user/event.rs
new file mode 100644
index 0000000..a748112
--- /dev/null
+++ b/src/user/event.rs
@@ -0,0 +1,36 @@
+use super::snapshot::User;
+use crate::event::{Instant, Sequenced};
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+#[serde(tag = "event", rename_all = "snake_case")]
+pub enum Event {
+ Created(Created),
+}
+
+impl Sequenced for Event {
+ fn instant(&self) -> Instant {
+ match self {
+ Self::Created(created) => created.instant(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+pub struct Created {
+ #[serde(flatten)]
+ pub instant: Instant,
+ #[serde(flatten)]
+ pub user: User,
+}
+
+impl Sequenced for Created {
+ fn instant(&self) -> Instant {
+ self.instant
+ }
+}
+
+impl From<Created> for Event {
+ fn from(event: Created) -> Self {
+ Self::Created(event)
+ }
+}
diff --git a/src/user/history.rs b/src/user/history.rs
new file mode 100644
index 0000000..ae7a561
--- /dev/null
+++ b/src/user/history.rs
@@ -0,0 +1,52 @@
+use super::{
+ Id, User,
+ event::{Created, Event},
+};
+use crate::event::{Instant, Sequence};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct History {
+ pub user: User,
+ pub created: Instant,
+}
+
+// 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()
+ }
+
+ pub fn as_of(&self, resume_point: Sequence) -> Option<User> {
+ self.events()
+ .filter(Sequence::up_to(resume_point))
+ .collect()
+ }
+
+ // Snapshot of this user, as of all events recorded in this history.
+ pub fn as_snapshot(&self) -> Option<User> {
+ self.events().collect()
+ }
+}
+
+// Events interface
+impl History {
+ fn created(&self) -> Event {
+ Created {
+ instant: self.created,
+ user: self.user.clone(),
+ }
+ .into()
+ }
+
+ pub fn events(&self) -> impl Iterator<Item = Event> + use<> {
+ [self.created()].into_iter()
+ }
+}
diff --git a/src/user/id.rs b/src/user/id.rs
new file mode 100644
index 0000000..9455deb
--- /dev/null
+++ b/src/user/id.rs
@@ -0,0 +1,24 @@
+use crate::id::Id as BaseId;
+
+// Stable identifier for a User. Prefixed with `L`.
+#[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, serde::Serialize)]
+#[sqlx(transparent)]
+pub struct Id(BaseId);
+
+impl From<BaseId> for Id {
+ fn from(id: BaseId) -> Self {
+ Self(id)
+ }
+}
+
+impl Id {
+ pub fn generate() -> Self {
+ BaseId::generate("L")
+ }
+}
+
+impl std::fmt::Display for Id {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
diff --git a/src/user/mod.rs b/src/user/mod.rs
new file mode 100644
index 0000000..f4c66ab
--- /dev/null
+++ b/src/user/mod.rs
@@ -0,0 +1,15 @@
+#[cfg(test)]
+pub mod app;
+pub mod create;
+pub mod event;
+mod history;
+mod id;
+pub mod password;
+pub mod repo;
+mod routes;
+mod snapshot;
+mod validate;
+
+pub use self::{
+ event::Event, history::History, id::Id, password::Password, routes::router, snapshot::User,
+};
diff --git a/src/user/password.rs b/src/user/password.rs
new file mode 100644
index 0000000..e1d164e
--- /dev/null
+++ b/src/user/password.rs
@@ -0,0 +1,65 @@
+use std::fmt;
+
+use argon2::Argon2;
+use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
+use rand_core::OsRng;
+
+use crate::normalize::nfc;
+
+#[derive(sqlx::Type)]
+#[sqlx(transparent)]
+pub struct StoredHash(String);
+
+impl StoredHash {
+ pub fn verify(&self, password: &Password) -> Result<bool, password_hash::Error> {
+ let hash = PasswordHash::new(&self.0)?;
+
+ match Argon2::default().verify_password(password.as_bytes(), &hash) {
+ // Successful authentication, not an error
+ Ok(()) => Ok(true),
+ // Unsuccessful authentication, also not an error
+ Err(password_hash::errors::Error::Password) => Ok(false),
+ // Password validation failed for some other reason, treat as an error
+ Err(err) => Err(err),
+ }
+ }
+}
+
+impl fmt::Debug for StoredHash {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("StoredHash").field(&"********").finish()
+ }
+}
+
+#[derive(Clone, serde::Deserialize)]
+#[serde(transparent)]
+pub struct Password(nfc::String);
+
+impl Password {
+ pub fn hash(&self) -> Result<StoredHash, password_hash::Error> {
+ let Self(password) = self;
+ let salt = SaltString::generate(&mut OsRng);
+ let argon2 = Argon2::default();
+ let hash = argon2
+ .hash_password(password.as_bytes(), &salt)?
+ .to_string();
+ Ok(StoredHash(hash))
+ }
+
+ fn as_bytes(&self) -> &[u8] {
+ let Self(value) = self;
+ value.as_bytes()
+ }
+}
+
+impl fmt::Debug for Password {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("Password").field(&"********").finish()
+ }
+}
+
+impl From<String> for Password {
+ fn from(password: String) -> Self {
+ Password(password.into())
+ }
+}
diff --git a/src/user/repo.rs b/src/user/repo.rs
new file mode 100644
index 0000000..c02d50f
--- /dev/null
+++ b/src/user/repo.rs
@@ -0,0 +1,153 @@
+use futures::stream::{StreamExt as _, TryStreamExt as _};
+use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
+
+use crate::{
+ clock::DateTime,
+ event::{Instant, Sequence},
+ name::{self, Name},
+ user::{History, Id, User, password::StoredHash},
+};
+
+pub trait Provider {
+ fn users(&mut self) -> Users;
+}
+
+impl Provider for Transaction<'_, Sqlite> {
+ fn users(&mut self) -> Users {
+ Users(self)
+ }
+}
+
+pub struct Users<'t>(&'t mut SqliteConnection);
+
+impl Users<'_> {
+ pub async fn create(
+ &mut self,
+ name: &Name,
+ password_hash: &StoredHash,
+ created: &Instant,
+ ) -> Result<History, sqlx::Error> {
+ let id = Id::generate();
+ let display_name = name.display();
+ let canonical_name = name.canonical();
+
+ sqlx::query!(
+ r#"
+ insert
+ into user (id, display_name, canonical_name, password_hash, created_sequence, created_at)
+ values ($1, $2, $3, $4, $5, $6)
+ "#,
+ id,
+ display_name,
+ canonical_name,
+ password_hash,
+ created.sequence,
+ created.at,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ let user = History {
+ created: *created,
+ user: User {
+ id,
+ name: name.clone(),
+ },
+ };
+
+ Ok(user)
+ }
+
+ pub async fn set_password(
+ &mut self,
+ login: &History,
+ to: &StoredHash,
+ ) -> Result<(), sqlx::Error> {
+ let login = login.id();
+
+ sqlx::query_scalar!(
+ r#"
+ update user
+ set password_hash = $1
+ where id = $2
+ returning id as "id: Id"
+ "#,
+ to,
+ login,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> {
+ let logins = sqlx::query!(
+ 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"
+ from user
+ where created_sequence <= $1
+ order by canonical_name
+ "#,
+ resume_at,
+ )
+ .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(&mut *self.0)
+ .map(|res| res?)
+ .try_collect()
+ .await?;
+
+ Ok(logins)
+ }
+
+ pub async fn replay(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> {
+ let logins = sqlx::query!(
+ 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"
+ from user
+ where created_sequence > $1
+ "#,
+ resume_at,
+ )
+ .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),
+ })
+ })
+ .fetch(&mut *self.0)
+ .map(|res| Ok::<_, LoadError>(res??))
+ .try_collect()
+ .await?;
+
+ Ok(logins)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub enum LoadError {
+ Database(#[from] sqlx::Error),
+ Name(#[from] name::Error),
+}
diff --git a/src/user/routes/login/mod.rs b/src/user/routes/login/mod.rs
new file mode 100644
index 0000000..36b384e
--- /dev/null
+++ b/src/user/routes/login/mod.rs
@@ -0,0 +1,4 @@
+pub mod post;
+
+#[cfg(test)]
+mod test;
diff --git a/src/user/routes/login/post.rs b/src/user/routes/login/post.rs
new file mode 100644
index 0000000..39f9eea
--- /dev/null
+++ b/src/user/routes/login/post.rs
@@ -0,0 +1,52 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+
+use crate::{
+ app::App,
+ clock::RequestedAt,
+ error::Internal,
+ name::Name,
+ token::{app, extract::IdentityCookie},
+ user::{Password, User},
+};
+
+pub async fn handler(
+ State(app): State<App>,
+ RequestedAt(now): RequestedAt,
+ identity: IdentityCookie,
+ Json(request): Json<Request>,
+) -> Result<(IdentityCookie, Json<User>), Error> {
+ let (user, secret) = app
+ .tokens()
+ .login(&request.name, &request.password, &now)
+ .await
+ .map_err(Error)?;
+ let identity = identity.set(secret);
+ Ok((identity, Json(user)))
+}
+
+#[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/routes/login/test.rs b/src/user/routes/login/test.rs
new file mode 100644
index 0000000..7399796
--- /dev/null
+++ b/src/user/routes/login/test.rs
@@ -0,0 +1,128 @@
+use axum::extract::{Json, State};
+
+use super::post;
+use crate::{test::fixtures, token::app};
+
+#[tokio::test]
+async fn correct_credentials() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let (name, password) = fixtures::login::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 = post::Request {
+ name: name.clone(),
+ password,
+ };
+ let (identity, Json(response)) =
+ post::handler(State(app.clone()), logged_in_at, identity, Json(request))
+ .await
+ .expect("logged in with valid credentials");
+
+ // Verify the return value's basic structure
+
+ assert_eq!(name, response.name);
+ let secret = identity
+ .secret()
+ .expect("logged in with valid credentials issues an identity cookie");
+
+ // Verify the semantics
+
+ let validated_at = fixtures::now();
+ let (_, validated_login) = app
+ .tokens()
+ .validate(&secret, &validated_at)
+ .await
+ .expect("identity secret is valid");
+
+ assert_eq!(response, validated_login);
+}
+
+#[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::login::propose();
+ let request = post::Request {
+ name: name.clone(),
+ password,
+ };
+ let post::Error(error) =
+ post::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::login::create(&app, &fixtures::now()).await;
+
+ // Call the endpoint
+
+ let logged_in_at = fixtures::now();
+ let identity = fixtures::cookie::not_logged_in();
+ let request = post::Request {
+ name: login.name,
+ password: fixtures::login::propose_password(),
+ };
+ let post::Error(error) =
+ post::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::login::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 = post::Request { name, password };
+ let (identity, _) = post::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");
+
+ 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));
+}
diff --git a/src/user/routes/logout/mod.rs b/src/user/routes/logout/mod.rs
new file mode 100644
index 0000000..36b384e
--- /dev/null
+++ b/src/user/routes/logout/mod.rs
@@ -0,0 +1,4 @@
+pub mod post;
+
+#[cfg(test)]
+mod test;
diff --git a/src/user/routes/logout/post.rs b/src/user/routes/logout/post.rs
new file mode 100644
index 0000000..bb09b9f
--- /dev/null
+++ b/src/user/routes/logout/post.rs
@@ -0,0 +1,47 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+
+use crate::{
+ app::App,
+ clock::RequestedAt,
+ error::{Internal, Unauthorized},
+ token::{app, extract::IdentityCookie},
+};
+
+pub async fn handler(
+ State(app): State<App>,
+ RequestedAt(now): RequestedAt,
+ identity: IdentityCookie,
+ Json(_): Json<Request>,
+) -> Result<(IdentityCookie, StatusCode), Error> {
+ if let Some(secret) = identity.secret() {
+ let (token, _) = app.tokens().validate(&secret, &now).await?;
+ app.tokens().logout(&token).await?;
+ }
+
+ let identity = identity.clear();
+ Ok((identity, StatusCode::NO_CONTENT))
+}
+
+// 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;
+ #[allow(clippy::match_wildcard_for_single_variants)]
+ match error {
+ app::ValidateError::InvalidToken => Unauthorized.into_response(),
+ other => Internal::from(other).into_response(),
+ }
+ }
+}
diff --git a/src/user/routes/logout/test.rs b/src/user/routes/logout/test.rs
new file mode 100644
index 0000000..775fa9f
--- /dev/null
+++ b/src/user/routes/logout/test.rs
@@ -0,0 +1,79 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+};
+
+use super::post;
+use crate::{test::fixtures, token::app};
+
+#[tokio::test]
+async fn successful() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let now = fixtures::now();
+ let creds = fixtures::login::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, response_status) = post::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());
+ assert_eq!(StatusCode::NO_CONTENT, response_status);
+
+ // Verify the semantics
+ let error = app
+ .tokens()
+ .validate(&secret, &now)
+ .await
+ .expect_err("secret is invalid");
+ assert!(matches!(error, app::ValidateError::InvalidToken));
+}
+
+#[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, status) = post::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());
+ assert_eq!(StatusCode::NO_CONTENT, status);
+}
+
+#[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 post::Error(error) = post::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/routes/mod.rs b/src/user/routes/mod.rs
new file mode 100644
index 0000000..ade96cb
--- /dev/null
+++ b/src/user/routes/mod.rs
@@ -0,0 +1,14 @@
+use axum::{Router, routing::post};
+
+use crate::app::App;
+
+mod login;
+mod logout;
+mod password;
+
+pub fn router() -> Router<App> {
+ Router::new()
+ .route("/api/password", post(password::post::handler))
+ .route("/api/auth/login", post(login::post::handler))
+ .route("/api/auth/logout", post(logout::post::handler))
+}
diff --git a/src/user/routes/password/mod.rs b/src/user/routes/password/mod.rs
new file mode 100644
index 0000000..36b384e
--- /dev/null
+++ b/src/user/routes/password/mod.rs
@@ -0,0 +1,4 @@
+pub mod post;
+
+#[cfg(test)]
+mod test;
diff --git a/src/user/routes/password/post.rs b/src/user/routes/password/post.rs
new file mode 100644
index 0000000..296f6cd
--- /dev/null
+++ b/src/user/routes/password/post.rs
@@ -0,0 +1,54 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+
+use crate::{
+ app::App,
+ clock::RequestedAt,
+ error::Internal,
+ token::{
+ app,
+ extract::{Identity, IdentityCookie},
+ },
+ user::{Password, User},
+};
+
+pub async fn handler(
+ State(app): State<App>,
+ RequestedAt(now): RequestedAt,
+ identity: Identity,
+ cookie: IdentityCookie,
+ Json(request): Json<Request>,
+) -> Result<(IdentityCookie, Json<User>), Error> {
+ let (login, secret) = app
+ .tokens()
+ .change_password(&identity.user, &request.password, &request.to, &now)
+ .await
+ .map_err(Error)?;
+ let cookie = cookie.set(secret);
+ Ok((cookie, Json(login)))
+}
+
+#[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/routes/password/test.rs b/src/user/routes/password/test.rs
new file mode 100644
index 0000000..01dcb38
--- /dev/null
+++ b/src/user/routes/password/test.rs
@@ -0,0 +1,68 @@
+use axum::extract::{Json, State};
+
+use super::post;
+use crate::{
+ test::fixtures,
+ token::app::{LoginError, ValidateError},
+};
+
+#[tokio::test]
+async fn password_change() {
+ // Set up the environment
+ let app = fixtures::scratch_app().await;
+ let creds = fixtures::login::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::login::propose_password();
+ let request = post::Request {
+ password: password.clone(),
+ to: to.clone(),
+ };
+ let (new_cookie, Json(response)) = post::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
+ assert_eq!(identity.user, response);
+
+ // Verify that our original token is no longer valid
+ let validate_err = app
+ .tokens()
+ .validate(
+ &cookie
+ .secret()
+ .expect("original identity cookie has a secret"),
+ &fixtures::now(),
+ )
+ .await
+ .expect_err("validating the original identity secret should fail");
+ assert!(matches!(validate_err, ValidateError::InvalidToken));
+
+ // Verify that our original password is no longer valid
+ let login_err = app
+ .tokens()
+ .login(&name, &password, &fixtures::now())
+ .await
+ .expect_err("logging in with the original password should fail");
+ assert!(matches!(login_err, LoginError::Rejected));
+
+ // Verify that our new password is valid
+ let (login, _) = app
+ .tokens()
+ .login(&name, &to, &fixtures::now())
+ .await
+ .expect("logging in with the new password should succeed");
+ assert_eq!(identity.user, login);
+}
diff --git a/src/user/snapshot.rs b/src/user/snapshot.rs
new file mode 100644
index 0000000..d548e06
--- /dev/null
+++ b/src/user/snapshot.rs
@@ -0,0 +1,52 @@
+use super::{
+ Id,
+ event::{Created, Event},
+};
+use crate::name::Name;
+
+// This also implements FromRequestParts (see `./extract.rs`). As a result, it
+// can be used as an extractor for endpoints that want to require a user, or for
+// endpoints that need to behave differently depending on whether the client is
+// or is not logged in.
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+pub struct User {
+ pub id: Id,
+ pub name: Name,
+ // The omission of the hashed password is deliberate, to minimize the
+ // chance that it ends up tangled up in debug output or in some other chunk
+ // of logic elsewhere.
+}
+
+impl User {
+ // Without this allow, clippy wants the `Option<Self>` return type to be `Self`. It's not a bad
+ // suggestion, but we need `Option` here, for two reasons:
+ //
+ // 1. This is used to collect streams using a fold, below, which requires a type
+ // consistent with the fold, and
+ // 2. It's also consistent with the other history state machine types.
+ #[allow(clippy::unnecessary_wraps)]
+ fn apply(state: Option<Self>, event: Event) -> Option<Self> {
+ match (state, event) {
+ (None, Event::Created(event)) => Some(event.into()),
+ (state, event) => panic!("invalid message event {event:#?} for state {state:#?}"),
+ }
+ }
+}
+
+impl FromIterator<Event> for Option<User> {
+ fn from_iter<I: IntoIterator<Item = Event>>(events: I) -> Self {
+ events.into_iter().fold(None, User::apply)
+ }
+}
+
+impl From<&Created> for User {
+ fn from(event: &Created) -> Self {
+ event.user.clone()
+ }
+}
+
+impl From<Created> for User {
+ fn from(event: Created) -> Self {
+ event.user
+ }
+}
diff --git a/src/user/validate.rs b/src/user/validate.rs
new file mode 100644
index 0000000..0c97293
--- /dev/null
+++ b/src/user/validate.rs
@@ -0,0 +1,23 @@
+use unicode_segmentation::UnicodeSegmentation as _;
+
+use crate::name::Name;
+
+// Picked out of a hat. The power of two is not meaningful.
+const NAME_TOO_LONG: usize = 64;
+
+pub fn name(name: &Name) -> bool {
+ let display = name.display();
+
+ [
+ display.graphemes(true).count() < NAME_TOO_LONG,
+ display.chars().all(|ch| !ch.is_control()),
+ display.chars().next().is_some_and(|c| !c.is_whitespace()),
+ display.chars().last().is_some_and(|c| !c.is_whitespace()),
+ display
+ .chars()
+ .zip(display.chars().skip(1))
+ .all(|(a, b)| !(a.is_whitespace() && b.is_whitespace())),
+ ]
+ .into_iter()
+ .all(|value| value)
+}