summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-10-29 23:29:22 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-10-29 23:29:22 -0400
commit66d3fcf2e22f057bacce8d97d43a13c1c5a9ad09 (patch)
tree60995943e14a6568cf2b37622ce97df121865a6d /src
parente328d33fc7d6a0f2e3d260d8bddee3ef633318eb (diff)
Add `change password` UI + API.
The protocol here re-checks the caller's password, as a "I left myself logged in" anti-pranking check.
Diffstat (limited to 'src')
-rw-r--r--src/event/routes/test/token.rs49
-rw-r--r--src/login/repo.rs23
-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/test/fixtures/identity.rs10
-rw-r--r--src/token/app.rs43
-rw-r--r--src/token/repo/auth.rs29
-rw-r--r--src/token/repo/token.rs18
-rw-r--r--src/token/secret.rs2
-rw-r--r--src/ui/routes/me.rs32
-rw-r--r--src/ui/routes/mod.rs2
13 files changed, 331 insertions, 5 deletions
diff --git a/src/event/routes/test/token.rs b/src/event/routes/test/token.rs
index 2039d9b..16ac7c3 100644
--- a/src/event/routes/test/token.rs
+++ b/src/event/routes/test/token.rs
@@ -93,3 +93,52 @@ async fn terminates_on_logout() {
.expect_none("end of stream")
.await;
}
+
+#[tokio::test]
+async fn terminates_on_password_change() {
+ // Set up the environment
+
+ let app = fixtures::scratch_app().await;
+ let channel = fixtures::channel::create(&app, &fixtures::now()).await;
+ let sender = fixtures::login::create(&app, &fixtures::now()).await;
+
+ // Subscribe via the endpoint
+
+ let creds = fixtures::login::create_with_password(&app, &fixtures::now()).await;
+ let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await;
+ let subscriber = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await;
+
+ let get::Response(events) = get::handler(
+ State(app.clone()),
+ subscriber.clone(),
+ None,
+ Query::default(),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Verify the resulting stream's behaviour
+
+ let (_, password) = creds;
+ let to = fixtures::login::propose_password();
+ app.tokens()
+ .change_password(&subscriber.login, &password, &to, &fixtures::now())
+ .await
+ .expect("expiring tokens succeeds");
+
+ // These should not be delivered.
+
+ let messages = [
+ fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await,
+ fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await,
+ fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await,
+ ];
+
+ events
+ .filter_map(fixtures::event::message)
+ .filter_map(fixtures::event::message::sent)
+ .filter(|event| future::ready(messages.iter().any(|message| &event.message == message)))
+ .next()
+ .expect_none("end of stream")
+ .await;
+}
diff --git a/src/login/repo.rs b/src/login/repo.rs
index 611edd6..a972304 100644
--- a/src/login/repo.rs
+++ b/src/login/repo.rs
@@ -58,6 +58,29 @@ impl<'c> Logins<'c> {
Ok(login)
}
+ 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: ResumePoint) -> Result<Vec<History>, LoadError> {
let logins = sqlx::query!(
r#"
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/test/fixtures/identity.rs b/src/test/fixtures/identity.rs
index e438f2b..ffc44c6 100644
--- a/src/test/fixtures/identity.rs
+++ b/src/test/fixtures/identity.rs
@@ -15,11 +15,15 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Identity {
logged_in(app, &credentials, created_at).await
}
-pub async fn from_cookie(app: &App, token: &IdentityCookie, issued_at: &RequestedAt) -> Identity {
- let secret = token.secret().expect("identity token has a secret");
+pub async fn from_cookie(
+ app: &App,
+ cookie: &IdentityCookie,
+ validated_at: &RequestedAt,
+) -> Identity {
+ let secret = cookie.secret().expect("identity token has a secret");
let (token, login) = app
.tokens()
- .validate(&secret, issued_at)
+ .validate(&secret, validated_at)
.await
.expect("always validates newly-issued secret");
diff --git a/src/token/app.rs b/src/token/app.rs
index c19d6a0..5c0aeb0 100644
--- a/src/token/app.rs
+++ b/src/token/app.rs
@@ -13,7 +13,7 @@ use super::{
use crate::{
clock::DateTime,
db::NotFound as _,
- login::{Login, Password},
+ login::{repo::Provider as _, Login, Password},
name::{self, Name},
};
@@ -61,6 +61,47 @@ impl<'a> Tokens<'a> {
Ok((snapshot, token))
}
+ pub async fn change_password(
+ &self,
+ login: &Login,
+ password: &Password,
+ to: &Password,
+ changed_at: &DateTime,
+ ) -> Result<(Login, Secret), LoginError> {
+ let mut tx = self.db.begin().await?;
+ let (login, stored_hash) = tx
+ .auth()
+ .for_login(login)
+ .await
+ .optional()?
+ .ok_or(LoginError::Rejected)?;
+ // Split the transaction here to avoid holding the tx open (potentially blocking
+ // other writes) while we do the fairly expensive task of verifying the
+ // password. It's okay if the token issuance transaction happens some notional
+ // amount of time after retrieving the login, as inserting the token will fail
+ // if the account is deleted during that time.
+ tx.commit().await?;
+
+ if !stored_hash.verify(password)? {
+ return Err(LoginError::Rejected);
+ }
+
+ let snapshot = login.as_snapshot().ok_or(LoginError::Rejected)?;
+ let to_hash = to.hash()?;
+
+ let mut tx = self.db.begin().await?;
+ let tokens = tx.tokens().revoke_all(&login).await?;
+ tx.logins().set_password(&login, &to_hash).await?;
+ let secret = tx.tokens().issue(&login, changed_at).await?;
+ tx.commit().await?;
+
+ for event in tokens.into_iter().map(TokenEvent::Revoked) {
+ self.token_events.broadcast(event);
+ }
+
+ Ok((snapshot, secret))
+ }
+
pub async fn validate(
&self,
secret: &Secret,
diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs
index bdc4c33..b51db8c 100644
--- a/src/token/repo/auth.rs
+++ b/src/token/repo/auth.rs
@@ -50,6 +50,35 @@ impl<'t> Auth<'t> {
Ok((login, row.password_hash))
}
+
+ pub async fn for_login(&mut self, login: &Login) -> Result<(History, StoredHash), LoadError> {
+ let row = sqlx::query!(
+ r#"
+ select
+ id as "id: login::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",
+ password_hash as "password_hash: StoredHash"
+ from login
+ where id = $1
+ "#,
+ login.id,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ let login = History {
+ login: Login {
+ id: row.id,
+ name: Name::new(row.display_name, row.canonical_name)?,
+ },
+ created: Instant::new(row.created_at, row.created_sequence),
+ };
+
+ Ok((login, row.password_hash))
+ }
}
#[derive(Debug, thiserror::Error)]
diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs
index 35ea385..33b89d5 100644
--- a/src/token/repo/token.rs
+++ b/src/token/repo/token.rs
@@ -84,6 +84,24 @@ impl<'c> Tokens<'c> {
Ok(())
}
+ // Revoke tokens for a login
+ pub async fn revoke_all(&mut self, login: &login::History) -> Result<Vec<Id>, sqlx::Error> {
+ let login = login.id();
+ let tokens = sqlx::query_scalar!(
+ r#"
+ delete
+ from token
+ where login = $1
+ returning id as "id: Id"
+ "#,
+ login,
+ )
+ .fetch_all(&mut *self.0)
+ .await?;
+
+ Ok(tokens)
+ }
+
// Expire and delete all tokens that haven't been used more recently than
// `expire_at`.
pub async fn expire(&mut self, expire_at: &DateTime) -> Result<Vec<Id>, sqlx::Error> {
diff --git a/src/token/secret.rs b/src/token/secret.rs
index 28c93bb..8f70646 100644
--- a/src/token/secret.rs
+++ b/src/token/secret.rs
@@ -1,6 +1,6 @@
use std::fmt;
-#[derive(sqlx::Type)]
+#[derive(PartialEq, Eq, sqlx::Type)]
#[sqlx(transparent)]
pub struct Secret(String);
diff --git a/src/ui/routes/me.rs b/src/ui/routes/me.rs
new file mode 100644
index 0000000..f1f118f
--- /dev/null
+++ b/src/ui/routes/me.rs
@@ -0,0 +1,32 @@
+pub mod get {
+ use axum::response::{self, IntoResponse, Redirect};
+
+ use crate::{
+ error::Internal,
+ token::extract::Identity,
+ ui::assets::{Asset, Assets},
+ };
+
+ pub async fn handler(identity: Option<Identity>) -> Result<Asset, Error> {
+ let _ = identity.ok_or(Error::NotLoggedIn)?;
+
+ Assets::index().map_err(Error::Internal)
+ }
+
+ #[derive(Debug, thiserror::Error)]
+ pub enum Error {
+ #[error("not logged in")]
+ NotLoggedIn,
+ #[error("{0}")]
+ Internal(Internal),
+ }
+
+ impl IntoResponse for Error {
+ fn into_response(self) -> response::Response {
+ match self {
+ Self::NotLoggedIn => Redirect::temporary("/login").into_response(),
+ Self::Internal(error) => error.into_response(),
+ }
+ }
+ }
+}
diff --git a/src/ui/routes/mod.rs b/src/ui/routes/mod.rs
index 72d9a4a..48b3f90 100644
--- a/src/ui/routes/mod.rs
+++ b/src/ui/routes/mod.rs
@@ -6,6 +6,7 @@ mod ch;
mod get;
mod invite;
mod login;
+mod me;
mod path;
mod setup;
@@ -16,6 +17,7 @@ pub fn router(app: &App) -> Router<App> {
.route("/setup", get(setup::get::handler)),
Router::new()
.route("/", get(get::handler))
+ .route("/me", get(me::get::handler))
.route("/login", get(login::get::handler))
.route("/ch/:channel", get(ch::channel::get::handler))
.route("/invite/:invite", get(invite::invite::get::handler))