diff options
Diffstat (limited to 'src/login')
| -rw-r--r-- | src/login/app.rs | 55 | ||||
| -rw-r--r-- | src/login/create.rs | 95 | ||||
| -rw-r--r-- | src/login/history.rs | 6 | ||||
| -rw-r--r-- | src/login/mod.rs | 3 | ||||
| -rw-r--r-- | src/login/repo.rs | 65 | ||||
| -rw-r--r-- | src/login/routes/mod.rs | 2 | ||||
| -rw-r--r-- | src/login/routes/password/mod.rs | 4 | ||||
| -rw-r--r-- | src/login/routes/password/post.rs | 54 | ||||
| -rw-r--r-- | src/login/routes/password/test.rs | 68 | ||||
| -rw-r--r-- | src/login/validate.rs | 23 |
10 files changed, 303 insertions, 72 deletions
diff --git a/src/login/app.rs b/src/login/app.rs index 2f5896f..f458561 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -1,65 +1,56 @@ use sqlx::sqlite::SqlitePool; -use super::repo::Provider as _; - -#[cfg(test)] -use super::{Login, Password}; -#[cfg(test)] -use crate::{ - clock::DateTime, - event::{repo::Provider as _, Broadcaster, Event}, - name::Name, +use super::{ + create::{self, Create}, + Login, Password, }; +use crate::{clock::DateTime, event::Broadcaster, name::Name}; pub struct Logins<'a> { db: &'a SqlitePool, - #[cfg(test)] events: &'a Broadcaster, } impl<'a> Logins<'a> { - #[cfg(not(test))] - pub const fn new(db: &'a SqlitePool) -> Self { - Self { db } - } - - #[cfg(test)] pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { Self { db, events } } - #[cfg(test)] pub async fn create( &self, name: &Name, password: &Password, created_at: &DateTime, ) -> Result<Login, CreateError> { - 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()) } - - pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { - let mut tx = self.db.begin().await?; - tx.logins().recanonicalize().await?; - tx.commit().await?; - - Ok(()) - } } #[derive(Debug, thiserror::Error)] -#[error(transparent)] pub enum CreateError { - Database(#[from] sqlx::Error), + #[error("invalid login 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/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/history.rs b/src/login/history.rs index daad579..8161b0b 100644 --- a/src/login/history.rs +++ b/src/login/history.rs @@ -2,7 +2,7 @@ use super::{ event::{Created, Event}, Id, Login, }; -use crate::event::{Instant, ResumePoint, Sequence}; +use crate::event::{Instant, Sequence}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct History { @@ -24,9 +24,9 @@ impl History { self.login.clone() } - pub fn as_of(&self, resume_point: impl Into<ResumePoint>) -> Option<Login> { + pub fn as_of(&self, resume_point: Sequence) -> Option<Login> { self.events() - .filter(Sequence::up_to(resume_point.into())) + .filter(Sequence::up_to(resume_point)) .collect() } diff --git a/src/login/mod.rs b/src/login/mod.rs index 279e9a6..006fa0c 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,4 +1,6 @@ +#[cfg(test)] pub mod app; +pub mod create; pub mod event; mod history; mod id; @@ -6,6 +8,7 @@ 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::Login, diff --git a/src/login/repo.rs b/src/login/repo.rs index 611edd6..1c63a4b 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -3,7 +3,7 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, - event::{Instant, ResumePoint, Sequence}, + event::{Instant, Sequence}, login::{password::StoredHash, History, Id, Login}, name::{self, Name}, }; @@ -58,7 +58,30 @@ impl<'c> Logins<'c> { Ok(login) } - pub async fn all(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, LoadError> { + pub async fn set_password( + &mut self, + login: &History, + to: &StoredHash, + ) -> Result<(), sqlx::Error> { + let login = login.id(); + + sqlx::query_scalar!( + r#" + update login + 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 @@ -68,7 +91,7 @@ impl<'c> Logins<'c> { created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login - where coalesce(created_sequence <= $1, true) + where created_sequence <= $1 order by canonical_name "#, resume_at, @@ -90,7 +113,7 @@ impl<'c> Logins<'c> { Ok(logins) } - pub async fn replay(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, LoadError> { + pub async fn replay(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> { let logins = sqlx::query!( r#" select @@ -100,7 +123,7 @@ impl<'c> Logins<'c> { created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login - where coalesce(login.created_sequence > $1, true) + where login.created_sequence > $1 "#, resume_at, ) @@ -120,38 +143,6 @@ impl<'c> Logins<'c> { Ok(logins) } - - pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { - let logins = sqlx::query!( - r#" - select - id as "id: Id", - display_name as "display_name: String" - from login - "#, - ) - .fetch_all(&mut *self.0) - .await?; - - for login in logins { - let name = Name::from(login.display_name); - let canonical_name = name.canonical(); - - sqlx::query!( - r#" - update login - set canonical_name = $1 - where id = $2 - "#, - canonical_name, - login.id, - ) - .execute(&mut *self.0) - .await?; - } - - Ok(()) - } } #[derive(Debug, thiserror::Error)] diff --git a/src/login/routes/mod.rs b/src/login/routes/mod.rs index 8cb8852..bbd0c3f 100644 --- a/src/login/routes/mod.rs +++ b/src/login/routes/mod.rs @@ -4,9 +4,11 @@ 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/login/routes/password/mod.rs b/src/login/routes/password/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/login/routes/password/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/login/routes/password/post.rs b/src/login/routes/password/post.rs new file mode 100644 index 0000000..4723754 --- /dev/null +++ b/src/login/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, + login::{Login, Password}, + token::{ + app, + extract::{Identity, IdentityCookie}, + }, +}; + +pub async fn handler( + State(app): State<App>, + RequestedAt(now): RequestedAt, + identity: Identity, + cookie: IdentityCookie, + Json(request): Json<Request>, +) -> Result<(IdentityCookie, Json<Login>), Error> { + let (login, secret) = app + .tokens() + .change_password(&identity.login, &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/login/routes/password/test.rs b/src/login/routes/password/test.rs new file mode 100644 index 0000000..c1974bf --- /dev/null +++ b/src/login/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.login, 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.login, login); +} diff --git a/src/login/validate.rs b/src/login/validate.rs new file mode 100644 index 0000000..0c97293 --- /dev/null +++ b/src/login/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) +} |
