summaryrefslogtreecommitdiff
path: root/src/login
diff options
context:
space:
mode:
Diffstat (limited to 'src/login')
-rw-r--r--src/login/app.rs55
-rw-r--r--src/login/create.rs95
-rw-r--r--src/login/history.rs6
-rw-r--r--src/login/mod.rs3
-rw-r--r--src/login/repo.rs65
-rw-r--r--src/login/routes/mod.rs2
-rw-r--r--src/login/routes/password/mod.rs4
-rw-r--r--src/login/routes/password/post.rs54
-rw-r--r--src/login/routes/password/test.rs68
-rw-r--r--src/login/validate.rs23
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)
+}