From 7ec7827cd956fa8371e12d66d32539c39374651e Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 26 Oct 2024 14:00:49 -0400 Subject: Invite accept error is Error --- src/invite/routes/invite/post.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index 0dd8dba..627eca3 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -36,7 +36,8 @@ pub struct Request { pub password: Password, } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] pub struct Error(pub app::AcceptError); impl IntoResponse for Error { -- cgit v1.2.3 From c85201406bf351298832f136b2a89a1bf5b17202 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 26 Oct 2024 14:01:06 -0400 Subject: Update saved SQL --- ...3fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json | 8 ++++---- ...0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json | 8 ++++---- ...00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json b/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json index e886759..c78dc38 100644 --- a/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json +++ b/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json @@ -44,12 +44,12 @@ }, "nullable": [ false, + true, + true, false, false, - false, - false, - false, - false + true, + true ] }, "hash": "a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f" diff --git a/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json b/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json index ce757ba..6f1c464 100644 --- a/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json +++ b/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json @@ -44,12 +44,12 @@ }, "nullable": [ false, + true, + true, false, false, - false, - false, - false, - false + true, + true ] }, "hash": "ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb" diff --git a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json index 6700c43..bdc3022 100644 --- a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json +++ b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json @@ -44,12 +44,12 @@ }, "nullable": [ false, + true, + true, false, false, - false, - false, - false, - false + true, + true ] }, "hash": "e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e" -- cgit v1.2.3 From 1859296186d22cae6eceb1acbd9d3e347d2e76db Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 25 Oct 2024 22:59:54 -0400 Subject: Package `hi` for Debian. This commit provides a Docker-based build process for generating `.deb` packages, which can be run in Docker Desktop. I don't love it, but it's the best option I have _right now_ for doing this. The resulting packages: * Install `hi` (and `hi-recanonicalize`), in `/usr/bin`. * Create a user (`hi`) and a data directory (`/var/lib/hi`). * Create and start a systemd service unit for `hi`. Packages are built for arm64 and amd64 (aka x86_64). --- Cargo.toml | 10 ++++++++ Dockerfile.builder | 7 ++++++ builder/image-setup | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ debian/hi.service | 10 ++++++++ debian/postinst | 10 ++++++++ docs/debian-packaging.md | 8 +++++++ tools/build-builder | 14 +++++++++++ tools/build-debian | 20 ++++++++++++++++ tools/version | 11 +++++++++ 9 files changed, 151 insertions(+) create mode 100644 Dockerfile.builder create mode 100755 builder/image-setup create mode 100644 debian/hi.service create mode 100755 debian/postinst create mode 100644 docs/debian-packaging.md create mode 100755 tools/build-builder create mode 100755 tools/build-debian create mode 100755 tools/version diff --git a/Cargo.toml b/Cargo.toml index 989f8cc..03b6a67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,16 @@ version = "0.1.0" edition = "2021" rust-version = "1.82" default-run = "hi" +authors = [ + "Owen Jacobson ", + "Kit La Touche ", +] + +[package.metadata.deb] +maintainer = "Owen Jacobson " +maintainer-scripts = "debian" + +[package.metadata.deb.systemd-units] [dependencies] argon2 = "0.5.3" diff --git a/Dockerfile.builder b/Dockerfile.builder new file mode 100644 index 0000000..da80c69 --- /dev/null +++ b/Dockerfile.builder @@ -0,0 +1,7 @@ +FROM rust:1-slim-bookworm + +COPY builder /opt/hi-builder +RUN /opt/hi-builder/image-setup + +RUN mkdir /app +WORKDIR /app diff --git a/builder/image-setup b/builder/image-setup new file mode 100755 index 0000000..6b7895e --- /dev/null +++ b/builder/image-setup @@ -0,0 +1,61 @@ +#!/bin/bash -e + +cd "$(dirname "$0")" + +apt-get update +apt-get install -y \ + apt-transport-https \ + ca-certificates \ + curl \ + dpkg-dev \ + gnupg \ + +# Key from , specifically +# +gpg --dearmor -o /usr/share/keyrings/nodesource.gpg <<'NODESOURCE_KEY' +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFdDN1ABCADaNd/I3j3tn40deQNgz7hB2NvT+syXe6k4ZmdiEcOfBvFrkS8B +hNS67t93etHsxEy7E0qwsZH32bKazMqe9zDwoa3aVImryjh6SHC9lMtW27JPHFeM +Srkt9YmH1WMwWcRO6eSY9B3PpazquhnvbammLuUojXRIxkDroy6Fw4UKmUNSRr32 +9Ej87jRoR1B2/57Kfp2Y4+vFGGzSvh3AFQpBHq51qsNHALU6+8PjLfIt+5TPvaWR +TB+kAZnQZkaIQM2nr1n3oj6ak2RATY/+kjLizgFWzgEfbCrbsyq68UoY5FPBnu4Z +E3iDZpaIqwKr0seUC7iA1xM5eHi5kty1oB7HABEBAAG0Ik5Tb2xpZCA8bnNvbGlk +LWdwZ0Bub2Rlc291cmNlLmNvbT6JATgEEwECACIFAldDN1ACGwMGCwkIBwMCBhUI +AgkKCwQWAgMBAh4BAheAAAoJEC9ZtfmbG+C0y7wH/i4xnab36dtrYW7RZwL8i6Sc +NjMx4j9+U1kr/F6YtqWd+JwCbBdar5zRghxPcYEq/qf7MbgAYcs1eSOuTOb7n7+o +xUwdH2iCtHhKh3Jr2mRw1ks7BbFZPB5KmkxHaEBfLT4d+I91ZuUdPXJ+0SXs9gzk +Dbz65Uhoz3W03aiF8HeL5JNARZFMbHHNVL05U1sTGTCOtu+1c/33f3TulQ/XZ3Y4 +hwGCpLe0Tv7g7Lp3iLMZMWYPEa0a7S4u8he5IEJQLd8bE8jltcQvrdr3Fm8kI2Jg +BJmUmX4PSfhuTCFaR/yeCt3UoW883bs9LfbTzIx9DJGpRIu8Y0IL3b4sj/GoZVq5 +AQ0EV0M3UAEIAKrTaC62ayzqOIPa7nS90BHHck4Z33a2tZF/uof38xNOiyWGhT8u +JeFoTTHn5SQq5Ftyu4K3K2fbbpuu/APQF05AaljzVkDGNMW4pSkgOasdysj831cu +ssrHX2RYS22wg80k6C/Hwmh5F45faEuNxsV+bPx7oPUrt5n6GMx84vEP3i1+FDBi +0pt/B/QnDFBXki1BGvJ35f5NwDefK8VaInxXP3ZN/WIbtn5dqxppkV/YkO7GiJlp +Jlju9rf3kKUIQzKQWxFsbCAPIHoWv7rH9RSxgDithXtG6Yg5R1aeBbJaPNXL9wpJ +YBJbiMjkAFaz4B95FOqZm3r7oHugiCGsHX0AEQEAAYkBHwQYAQIACQUCV0M3UAIb +DAAKCRAvWbX5mxvgtE/OB/0VN88DR3Y3fuqy7lq/dthkn7Dqm9YXdorZl3L152eE +IF882aG8FE3qZdaLGjQO4oShAyNWmRfSGuoH0XERXAI9n0r8m4mDMxE6rtP7tHet +y/5M8x3CTyuMgx5GLDaEUvBusnTD+/v/fBMwRK/cZ9du5PSG4R50rtst+oYyC2ao +x4I2SgjtF/cY7bECsZDplzatN3gv34PkcdIg8SLHAVlL4N5tzumDeizRspcSyoy2 +K2+hwKU4C4+dekLLTg8rjnRROvplV2KtaEk6rxKtIRFDCoQng8wfJuIMrDNKvqZw +FRGt7cbvW5MCnuH8MhItOl9Uxp1wHp6gtav/h8Gp6MBa +=MARt +-----END PGP PUBLIC KEY BLOCK----- +NODESOURCE_KEY + +cat > /etc/apt/sources.list.d/nodesource.list < /etc/apt/preferences.d/nodejs <<'NODEJS_PIN' +Package: nodejs +Pin: origin deb.nodesource.com +Pin-Priority: 600 +NODEJS_PIN + +apt-get update +apt-get install -y \ + nodejs + +cargo install cargo-deb diff --git a/debian/hi.service b/debian/hi.service new file mode 100644 index 0000000..ec444c8 --- /dev/null +++ b/debian/hi.service @@ -0,0 +1,10 @@ +[Unit] +Description=Hi chat service +After=network-online.target + +[Service] +ExecStart=/usr/bin/hi --database-url sqlite:///var/lib/hi/hi.db +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..b7d02c3 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,10 @@ +#!/bin/sh +set -ex + +adduser \ + --system \ + --group \ + --home /var/lib/hi \ + hi + +#DEBHELPER# diff --git a/docs/debian-packaging.md b/docs/debian-packaging.md new file mode 100644 index 0000000..f307536 --- /dev/null +++ b/docs/debian-packaging.md @@ -0,0 +1,8 @@ +# Building Debian packages + +You will need `docker` installed, and set up to use the `containerd` storage backend. The builder image is built as a multi-architecture image. + +1. Run `tools/build-builder`. +2. Run `tools/build-debian`. + +Packages will be built in `target/debian`. diff --git a/tools/build-builder b/tools/build-builder new file mode 100755 index 0000000..fcb1e84 --- /dev/null +++ b/tools/build-builder @@ -0,0 +1,14 @@ +#!/bin/bash -e + +## tools/build-builder +## +## Builds a Docker image containing the Debian package builder. + +cd "$(dirname "$0")/.." + +docker build \ + --platform "linux/arm64,linux/amd64" \ + --tag "hi-debian-builder:$(tools/version)" \ + --tag "hi-debian-builder:latest" \ + --file Dockerfile.builder \ + . diff --git a/tools/build-debian b/tools/build-debian new file mode 100755 index 0000000..c64fc78 --- /dev/null +++ b/tools/build-debian @@ -0,0 +1,20 @@ +#!/bin/bash -e + +## tools/build-builder +## +## Builds a Debian package for the current tree. Output in ./target/debian. +## +## Requires the Debian builder (see tools/build-builder). + +cd "$(dirname "$0")/.." + +for platform in linux/arm64 linux/amd64; do + docker run \ + --platform "$platform" \ + --rm \ + --interactive \ + --tty \ + --volume "$PWD:/app" \ + "hi-debian-builder:$(tools/version)" \ + cargo deb +done diff --git a/tools/version b/tools/version new file mode 100755 index 0000000..8e47a7c --- /dev/null +++ b/tools/version @@ -0,0 +1,11 @@ +#!/bin/bash -e + +## tools/version +## +## Print the current version of the project to stdout. Data comes from Cargo. + +cd "$(dirname "$0")/.." + +cargo metadata \ + --format-version 1 | +jq -r '.packages[] | select(.name == "hi") | .version' -- cgit v1.2.3 From 5fa6c3169cc0bf156a2003d794c45a41ebaa34d0 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 17:51:01 -0400 Subject: Stop logging every step of postinst --- debian/postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/postinst b/debian/postinst index b7d02c3..d88a7ad 100755 --- a/debian/postinst +++ b/debian/postinst @@ -1,5 +1,5 @@ #!/bin/sh -set -ex +set -e adduser \ --system \ -- cgit v1.2.3 From 8f9805bf171d5d04fa25e709c12b861ef092b2bf Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:33:28 -0400 Subject: Update stored sqlx queries --- ...657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json | 2 +- ...3fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json | 8 ++++---- ...0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json | 8 ++++---- ...00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json index 5442790..be5b784 100644 --- a/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json +++ b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json @@ -11,7 +11,7 @@ { "name": "display_name: String", "ordinal": 1, - "type_info": "Text" + "type_info": "Null" } ], "parameters": { diff --git a/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json b/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json index c78dc38..e886759 100644 --- a/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json +++ b/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json @@ -44,12 +44,12 @@ }, "nullable": [ false, - true, - true, false, false, - true, - true + false, + false, + false, + false ] }, "hash": "a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f" diff --git a/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json b/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json index 6f1c464..ce757ba 100644 --- a/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json +++ b/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json @@ -44,12 +44,12 @@ }, "nullable": [ false, - true, - true, false, false, - true, - true + false, + false, + false, + false ] }, "hash": "ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb" diff --git a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json index bdc3022..6700c43 100644 --- a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json +++ b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json @@ -44,12 +44,12 @@ }, "nullable": [ false, - true, - true, false, false, - true, - true + false, + false, + false, + false ] }, "hash": "e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e" -- cgit v1.2.3 From da485e523913df28def6335be0836b1fc437617f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 19:32:30 -0400 Subject: Restrict login names. There's no good reason to use an empty string as your login name, or to use one so long as to annoy others. Names beginning or ending with whitespace, or containing runs of whitespace, are also a technical problem, so they're also prohibited. This change does not implement [UTS #39], as I haven't yet fully understood how to do so. [UTS #39]: https://www.unicode.org/reports/tr39/ --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + docs/api/initial-setup.md | 14 ++++++++++++++ docs/api/invitations.md | 13 +++++++++++++ src/invite/app.rs | 8 +++++++- src/invite/routes/invite/post.rs | 3 +++ src/invite/routes/invite/test/post.rs | 32 ++++++++++++++++++++++++++++++++ src/login/app.rs | 12 ++++++++++-- src/login/mod.rs | 1 + src/login/validate.rs | 23 +++++++++++++++++++++++ src/setup/app.rs | 8 +++++++- src/setup/routes/post.rs | 3 +++ src/setup/routes/test.rs | 25 +++++++++++++++++++++++++ src/test/fixtures/login.rs | 6 +++++- 14 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 src/login/validate.rs diff --git a/Cargo.lock b/Cargo.lock index f5ba5ff..8e10aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,6 +828,7 @@ dependencies = [ "tokio-stream", "unicode-casefold", "unicode-normalization", + "unicode-segmentation", "unix_path", "uuid", ] @@ -2142,6 +2143,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 03b6a67..630ebe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1.16", features = ["sync"] } unicode-casefold = "0.2.0" unicode-normalization = "0.1.24" +unicode-segmentation = "1.12.0" unix_path = "1.0.1" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/docs/api/initial-setup.md b/docs/api/initial-setup.md index 306d798..b6bf270 100644 --- a/docs/api/initial-setup.md +++ b/docs/api/initial-setup.md @@ -51,6 +51,16 @@ The request must have the following fields: | `name` | string | The initial login's name. | | `password` | string | The initial login's password, in plain text. | + + +The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: + +* It must be non-empty. +* It must not be "too long." (Currently, 64 characters is too long.) +* It must begin with an alphanumeric character. +* It must end with an alphanumeric character. +* It must not contain runs of multiple whitespace characters. + ### Success @@ -79,6 +89,10 @@ The response will include a `Set-Cookie` header for the `identity` cookie, provi The cookie will expire if it is not used regularly. +### Name not valid + +This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. + ### Setup previously completed Once completed, this operation cannot be performed a second time. Subsequent requests to this endpoint will respond with a status of `409 Conflict`. diff --git a/docs/api/invitations.md b/docs/api/invitations.md index ddbef8a..83e5145 100644 --- a/docs/api/invitations.md +++ b/docs/api/invitations.md @@ -130,6 +130,15 @@ The request must have the following fields: | `name` | string | The new login's name. | | `password` | string | The new login's password, in plain text. | + +The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: + +* It must be non-empty. +* It must not be "too long." (Currently, 64 characters is too long.) +* It must begin with an alphanumeric character. +* It must end with an alphanumeric character. +* It must not contain runs of multiple whitespace characters. + ### Success @@ -162,6 +171,10 @@ The cookie will expire if it is not used regularly. This endpoint will respond with a status of `404 Not Found` when the invitation ID either does not exist, or has already been accepted. +### Name not valid + +This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. + ### Name in use This endpoint will respond with a status of `409 Conflict` if the requested login name has already been taken. diff --git a/src/invite/app.rs b/src/invite/app.rs index 176075f..182eb67 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -6,7 +6,7 @@ use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Login, Password}, + login::{repo::Provider as _, validate, Login, Password}, name::Name, token::{repo::Provider as _, Secret}, }; @@ -44,6 +44,10 @@ impl<'a> Invites<'a> { password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { + if !validate::name(name) { + return Err(AcceptError::InvalidName(name.clone())); + } + let mut tx = self.db.begin().await?; let invite = tx .invites() @@ -92,6 +96,8 @@ impl<'a> Invites<'a> { pub enum AcceptError { #[error("invite not found: {0}")] NotFound(Id), + #[error("invalid login name: {0}")] + InvalidName(Name), #[error("name in use: {0}")] DuplicateLogin(Name), #[error(transparent)] diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index 627eca3..bb68e07 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -45,6 +45,9 @@ impl IntoResponse for Error { let Self(error) = self; match error { app::AcceptError::NotFound(_) => NotFound(error).into_response(), + app::AcceptError::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } app::AcceptError::DuplicateLogin(_) => { (StatusCode::CONFLICT, error.to_string()).into_response() } diff --git a/src/invite/routes/invite/test/post.rs b/src/invite/routes/invite/test/post.rs index 65ab61e..40e0580 100644 --- a/src/invite/routes/invite/test/post.rs +++ b/src/invite/routes/invite/test/post.rs @@ -206,3 +206,35 @@ async fn conflicting_name() { matches!(error, AcceptError::DuplicateLogin(error_name) if error_name == conflicting_name) ); } + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::login::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::login::propose_invalid_name(); + let password = fixtures::login::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = post::Request { + name: name.clone(), + password: password.clone(), + }; + let post::Error(error) = post::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id), + Json(request), + ) + .await + .expect_err("using an invalid name should fail"); + + // Verify the response + + assert!(matches!(error, AcceptError::InvalidName(error_name) if name == error_name)); +} diff --git a/src/login/app.rs b/src/login/app.rs index 2f5896f..c1bfe6e 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -3,7 +3,7 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; #[cfg(test)] -use super::{Login, Password}; +use super::{validate, Login, Password}; #[cfg(test)] use crate::{ clock::DateTime, @@ -35,6 +35,10 @@ impl<'a> Logins<'a> { password: &Password, created_at: &DateTime, ) -> Result { + if !validate::name(name) { + return Err(CreateError::InvalidName(name.clone())); + } + let password_hash = password.hash()?; let mut tx = self.db.begin().await?; @@ -57,9 +61,13 @@ impl<'a> Logins<'a> { } } +#[cfg(test)] #[derive(Debug, thiserror::Error)] -#[error(transparent)] pub enum CreateError { + #[error("invalid login name: {0}")] + InvalidName(Name), + #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] PasswordHash(#[from] password_hash::Error), } diff --git a/src/login/mod.rs b/src/login/mod.rs index 279e9a6..6d10e17 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -6,6 +6,7 @@ pub mod password; pub mod repo; mod routes; mod snapshot; +pub mod validate; pub use self::{ event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, diff --git a/src/login/validate.rs b/src/login/validate.rs new file mode 100644 index 0000000..ed3eff8 --- /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(char::is_alphanumeric), + display.chars().last().is_some_and(char::is_alphanumeric), + display + .chars() + .zip(display.chars().skip(1)) + .all(|(a, b)| !(a.is_whitespace() && b.is_whitespace())), + ] + .into_iter() + .all(|value| value) +} diff --git a/src/setup/app.rs b/src/setup/app.rs index 030b5f6..cab7c4b 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -4,7 +4,7 @@ use super::repo::Provider as _; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Login, Password}, + login::{repo::Provider as _, validate, Login, Password}, name::Name, token::{repo::Provider as _, Secret}, }; @@ -25,6 +25,10 @@ impl<'a> Setup<'a> { password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { + if !validate::name(name) { + return Err(Error::InvalidName(name.clone())); + } + let password_hash = password.hash()?; let mut tx = self.db.begin().await?; @@ -56,6 +60,8 @@ impl<'a> Setup<'a> { pub enum Error { #[error("initial setup previously completed")] SetupCompleted, + #[error("invalid login name: {0}")] + InvalidName(Name), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index f7b256e..2a46b04 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -42,6 +42,9 @@ impl IntoResponse for Error { fn into_response(self) -> Response { let Self(error) = self; match error { + app::Error::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), other => Internal::from(other).into_response(), } diff --git a/src/setup/routes/test.rs b/src/setup/routes/test.rs index f7562ae..5794b78 100644 --- a/src/setup/routes/test.rs +++ b/src/setup/routes/test.rs @@ -67,3 +67,28 @@ async fn login_exists() { assert!(matches!(error, app::Error::SetupCompleted)); } + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let name = fixtures::login::propose_invalid_name(); + let password = fixtures::login::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = post::Request { + name: name.clone(), + password: password.clone(), + }; + let post::Error(error) = + post::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + .await + .expect_err("setup with an invalid name fails"); + + // Verify the response + + assert!(matches!(error, app::Error::InvalidName(error_name) if name == error_name)); +} diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index e308289..86e3e39 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -1,4 +1,4 @@ -use faker_rand::en_us::internet; +use faker_rand::{en_us::internet, lorem::Paragraphs}; use uuid::Uuid; use crate::{ @@ -38,6 +38,10 @@ pub fn propose() -> (Name, Password) { (propose_name(), propose_password()) } +pub fn propose_invalid_name() -> Name { + rand::random::().to_string().into() +} + fn propose_name() -> Name { rand::random::().to_string().into() } -- cgit v1.2.3 From 9ae0faf4f027caaaf3bc4a42738d4ed31e67852d Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:26:47 -0400 Subject: Create a dedicated workflow type for creating logins. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`). --- src/invite/app.rs | 33 +++++++++++-------- src/login/app.rs | 38 +++++++++++---------- src/login/create.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/login/mod.rs | 3 +- src/setup/app.rs | 32 +++++++++++------- 5 files changed, 158 insertions(+), 43 deletions(-) create mode 100644 src/login/create.rs diff --git a/src/invite/app.rs b/src/invite/app.rs index 182eb67..d4e877a 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -5,8 +5,11 @@ use super::{repo::Provider as _, Id, Invite, Summary}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -44,9 +47,7 @@ impl<'a> Invites<'a> { password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { - if !validate::name(name) { - return Err(AcceptError::InvalidName(name.clone())); - } + let create = Create::begin(name, password, accepted_at); let mut tx = self.db.begin().await?; let invite = tx @@ -59,23 +60,20 @@ impl<'a> Invites<'a> { // the invite. Final validation is in the next tx. tx.commit().await?; - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; // If the invite has been deleted or accepted in the interim, this step will // catch it. tx.invites().accept(&invite).await?; - let created = tx.sequence().next(accepted_at).await?; - let login = tx - .logins() - .create(name, &password_hash, &created) + let stored = validated + .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(&login, accepted_at).await?; + let secret = tx.tokens().issue(stored.login(), accepted_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -105,3 +103,12 @@ pub enum AcceptError { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for AcceptError { + 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/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 { - 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::>()); + 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 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, 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 { + 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::>()); + + 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, diff --git a/src/setup/app.rs b/src/setup/app.rs index cab7c4b..c1f7b69 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -3,8 +3,11 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; use crate::{ clock::DateTime, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -25,24 +28,20 @@ impl<'a> Setup<'a> { password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { - if !validate::name(name) { - return Err(Error::InvalidName(name.clone())); - } + let create = Create::begin(name, password, created_at); - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; - let login = if tx.setup().completed().await? { + let stored = if tx.setup().completed().await? { Err(Error::SetupCompleted)? } else { - let created = tx.sequence().next(created_at).await?; - tx.logins().create(name, &password_hash, &created).await? + validated.store(&mut tx).await? }; - let secret = tx.tokens().issue(&login, created_at).await?; + let secret = tx.tokens().issue(stored.login(), created_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -67,3 +66,12 @@ pub enum Error { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for Error { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} -- cgit v1.2.3 From 15311c7bd816a83d0641de6b6bb3c41bb67079db Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:41:58 -0400 Subject: fixup! Restrict login names. --- docs/api/initial-setup.md | 4 ++-- docs/api/invitations.md | 4 ++-- src/login/validate.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api/initial-setup.md b/docs/api/initial-setup.md index b6bf270..c2bdaec 100644 --- a/docs/api/initial-setup.md +++ b/docs/api/initial-setup.md @@ -57,8 +57,8 @@ The proposed `name` must be valid. The precise definition of valid is still up i * It must be non-empty. * It must not be "too long." (Currently, 64 characters is too long.) -* It must begin with an alphanumeric character. -* It must end with an alphanumeric character. +* It must begin with a printing character. +* It must end with a printing character. * It must not contain runs of multiple whitespace characters. ### Success diff --git a/docs/api/invitations.md b/docs/api/invitations.md index 83e5145..1839ef5 100644 --- a/docs/api/invitations.md +++ b/docs/api/invitations.md @@ -135,8 +135,8 @@ The proposed `name` must be valid. The precise definition of valid is still up i * It must be non-empty. * It must not be "too long." (Currently, 64 characters is too long.) -* It must begin with an alphanumeric character. -* It must end with an alphanumeric character. +* It must begin with a printing character. +* It must end with a printing character. * It must not contain runs of multiple whitespace characters. ### Success diff --git a/src/login/validate.rs b/src/login/validate.rs index ed3eff8..0c97293 100644 --- a/src/login/validate.rs +++ b/src/login/validate.rs @@ -11,8 +11,8 @@ pub fn name(name: &Name) -> bool { [ display.graphemes(true).count() < NAME_TOO_LONG, display.chars().all(|ch| !ch.is_control()), - display.chars().next().is_some_and(char::is_alphanumeric), - display.chars().last().is_some_and(char::is_alphanumeric), + 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)) -- cgit v1.2.3 From 9010c7feeca8f4e7e501ad474911deaaf7a1a367 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:44:03 -0400 Subject: Restrict channel names, too. Thankfully, channel creation only happens in one place, so we don't need a state machine for this. --- docs/api/channels-messages.md | 14 +++++++++++++- src/channel/app.rs | 8 +++++++- src/channel/mod.rs | 1 + src/channel/routes/post.rs | 3 +++ src/channel/routes/test.rs | 24 ++++++++++++++++++++++++ src/channel/validate.rs | 23 +++++++++++++++++++++++ src/test/fixtures/channel.rs | 5 +++++ 7 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/channel/validate.rs diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index 9854d22..6391b5a 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -64,6 +64,14 @@ The request must have the following fields: |:-------|:-------|:--| | `name` | string | The channel's name. | +The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: + +* It must be non-empty. +* It must not be "too long." (Currently, 64 characters is too long.) +* It must begin with a printing character. +* It must end with a printing character. +* It must not contain runs of multiple whitespace characters. + ### Success This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the new channel: @@ -86,7 +94,11 @@ The returned name may not be identical to the name requested, as the name will b When completed, the service will emit a [channel created](events.md#channel-created) event with the channel's ID. -### Duplicate channel name +### Name not valid + +This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. + +### Channel name in use This endpoint will respond with a status of `409 Conflict` if a channel with the requested name already exists. diff --git a/src/channel/app.rs b/src/channel/app.rs index 8359277..9a19b16 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -4,7 +4,7 @@ use sqlx::sqlite::SqlitePool; use super::{ repo::{LoadError, Provider as _}, - Channel, Id, + validate, Channel, Id, }; use crate::{ clock::DateTime, @@ -25,6 +25,10 @@ impl<'a> Channels<'a> { } pub async fn create(&self, name: &Name, created_at: &DateTime) -> Result { + if !validate::name(name) { + return Err(CreateError::InvalidName(name.clone())); + } + let mut tx = self.db.begin().await?; let created = tx.sequence().next(created_at).await?; let channel = tx @@ -149,6 +153,8 @@ impl<'a> Channels<'a> { pub enum CreateError { #[error("channel named {0} already exists")] DuplicateName(Name), + #[error("invalid channel name: {0}")] + InvalidName(Name), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/channel/mod.rs b/src/channel/mod.rs index eb8200b..d5ba828 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -5,5 +5,6 @@ mod id; pub mod repo; mod routes; mod snapshot; +mod validate; pub use self::{event::Event, history::History, id::Id, routes::router, snapshot::Channel}; diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs index 810445c..2cf1cc0 100644 --- a/src/channel/routes/post.rs +++ b/src/channel/routes/post.rs @@ -54,6 +54,9 @@ impl IntoResponse for Error { app::CreateError::DuplicateName(_) => { (StatusCode::CONFLICT, error.to_string()).into_response() } + app::CreateError::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } other => Internal::from(other).into_response(), } } diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs index 10b1e8d..f5369fb 100644 --- a/src/channel/routes/test.rs +++ b/src/channel/routes/test.rs @@ -115,6 +115,30 @@ async fn conflicting_canonical_name() { )); } +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::channel::propose_invalid_name(); + let request = post::Request { name: name.clone() }; + let post::Error(error) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect_err("invalid channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::InvalidName(error_name) if name == error_name + )); +} + #[tokio::test] async fn name_reusable_after_delete() { // Set up the environment diff --git a/src/channel/validate.rs b/src/channel/validate.rs new file mode 100644 index 0000000..0c97293 --- /dev/null +++ b/src/channel/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) +} diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs index 0c6480b..98048f2 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/channel.rs @@ -1,6 +1,7 @@ use faker_rand::{ en_us::{addresses::CityName, names::FullName}, faker_impl_from_templates, + lorem::Paragraphs, }; use rand; @@ -23,6 +24,10 @@ pub fn propose() -> Name { rand::random::().to_string().into() } +pub fn propose_invalid_name() -> Name { + rand::random::().to_string().into() +} + struct NameTemplate(String); faker_impl_from_templates! { NameTemplate; "{} {}", CityName, FullName; -- cgit v1.2.3 From e328d33fc7d6a0f2e3d260d8bddee3ef633318eb Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 22:11:36 -0400 Subject: Restrict deletion to deleting your own messages. --- docs/api/channels-messages.md | 4 ++++ src/boot/routes/test.rs | 2 +- src/event/routes/test/message.rs | 6 +++--- src/message/app.rs | 14 ++++++++++++-- src/message/routes/message/mod.rs | 9 +++++++-- src/message/routes/message/test.rs | 37 ++++++++++++++++++++++++++++++++----- 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index 6391b5a..d87a01c 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -227,3 +227,7 @@ When completed, the service will emit a [message deleted](events.md#message-dele ### Invalid message ID This endpoint will respond with a status of `404 Not Found` if the message ID is not valid. + +### Not the sender + +This endpoint will respond with a status of `403 Forbidden` if the message was sent by a different login. diff --git a/src/boot/routes/test.rs b/src/boot/routes/test.rs index 8808b70..202dcb9 100644 --- a/src/boot/routes/test.rs +++ b/src/boot/routes/test.rs @@ -85,7 +85,7 @@ async fn excludes_deleted_messages() { let deleted_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; app.messages() - .delete(&deleted_message.id, &fixtures::now()) + .delete(&sender, &deleted_message.id, &fixtures::now()) .await .expect("deleting valid message succeeds"); diff --git a/src/event/routes/test/message.rs b/src/event/routes/test/message.rs index 63a3f43..a7b25fb 100644 --- a/src/event/routes/test/message.rs +++ b/src/event/routes/test/message.rs @@ -260,7 +260,7 @@ async fn deleting() { // Delete the message app.messages() - .delete(&message.id, &fixtures::now()) + .delete(&sender, &message.id, &fixtures::now()) .await .expect("deleting a valid message succeeds"); @@ -286,7 +286,7 @@ async fn previously_deleted() { // Delete the message app.messages() - .delete(&message.id, &fixtures::now()) + .delete(&sender, &message.id, &fixtures::now()) .await .expect("deleting a valid message succeeds"); @@ -320,7 +320,7 @@ async fn previously_purged() { // Purge the message app.messages() - .delete(&message.id, &fixtures::ancient()) + .delete(&sender, &message.id, &fixtures::ancient()) .await .expect("deleting a valid message succeeds"); diff --git a/src/message/app.rs b/src/message/app.rs index eed6ba4..137a27d 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -45,16 +45,24 @@ impl<'a> Messages<'a> { Ok(message.as_sent()) } - pub async fn delete(&self, message: &Id, deleted_at: &DateTime) -> Result<(), DeleteError> { + pub async fn delete( + &self, + deleted_by: &Login, + message: &Id, + deleted_at: &DateTime, + ) -> Result<(), DeleteError> { let mut tx = self.db.begin().await?; let message = tx .messages() .by_id(message) .await .not_found(|| DeleteError::NotFound(message.clone()))?; - message + let snapshot = message .as_snapshot() .ok_or_else(|| DeleteError::Deleted(message.id().clone()))?; + if snapshot.sender != deleted_by.id { + return Err(DeleteError::NotSender(deleted_by.clone())); + } let deleted = tx.sequence().next(deleted_at).await?; let message = tx.messages().delete(&message, &deleted).await?; @@ -138,6 +146,8 @@ impl From for SendError { pub enum DeleteError { #[error("message {0} not found")] NotFound(Id), + #[error("login {} not the message's sender", .0.id)] + NotSender(Login), #[error("message {0} deleted")] Deleted(Id), #[error(transparent)] diff --git a/src/message/routes/message/mod.rs b/src/message/routes/message/mod.rs index 45a7e9d..e92f556 100644 --- a/src/message/routes/message/mod.rs +++ b/src/message/routes/message/mod.rs @@ -20,9 +20,11 @@ pub mod delete { State(app): State, Path(message): Path, RequestedAt(deleted_at): RequestedAt, - _: Identity, + identity: Identity, ) -> Result { - app.messages().delete(&message, &deleted_at).await?; + app.messages() + .delete(&identity.login, &message, &deleted_at) + .await?; Ok(Response { id: message }) } @@ -47,6 +49,9 @@ pub mod delete { let Self(error) = self; #[allow(clippy::match_wildcard_for_single_variants)] match error { + DeleteError::NotSender(_) => { + (StatusCode::FORBIDDEN, error.to_string()).into_response() + } DeleteError::NotFound(_) | DeleteError::Deleted(_) => { NotFound(error).into_response() } diff --git a/src/message/routes/message/test.rs b/src/message/routes/message/test.rs index ae89506..5178ab5 100644 --- a/src/message/routes/message/test.rs +++ b/src/message/routes/message/test.rs @@ -8,18 +8,17 @@ pub async fn delete_message() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender.login, &fixtures::now()).await; // Send the request - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let response = delete::handler( State(app.clone()), Path(message.id.clone()), fixtures::now(), - deleter, + sender, ) .await .expect("deleting a valid message succeeds"); @@ -68,7 +67,7 @@ pub async fn delete_deleted() { let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; app.messages() - .delete(&message.id, &fixtures::now()) + .delete(&sender, &message.id, &fixtures::now()) .await .expect("deleting a recently-sent message succeeds"); @@ -155,3 +154,31 @@ pub async fn delete_purged() { assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id)); } + +#[tokio::test] +pub async fn delete_not_sender() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let delete::Error(error) = delete::handler( + State(app.clone()), + Path(message.id.clone()), + fixtures::now(), + deleter.clone(), + ) + .await + .expect_err("deleting a message someone else sent fails"); + + // Verify the response + + assert!( + matches!(error, app::DeleteError::NotSender(error_sender) if deleter.login == error_sender) + ); +} -- cgit v1.2.3 From 66d3fcf2e22f057bacce8d97d43a13c1c5a9ad09 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 23:29:22 -0400 Subject: Add `change password` UI + API. The protocol here re-checks the caller's password, as a "I left myself logged in" anti-pranking check. --- ...fd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json | 20 +++++++ ...32d8eb2d9c2bb4de2fbbf0b2280973966ef02f72b1.json | 50 ++++++++++++++++ ...c233ed32638e853e3b8b8f8de26b53f90c98b6ce11.json | 20 +++++++ docs/api/authentication.md | 47 +++++++++++++++ src/event/routes/test/token.rs | 49 ++++++++++++++++ src/login/repo.rs | 23 ++++++++ src/login/routes/mod.rs | 2 + src/login/routes/password/mod.rs | 4 ++ src/login/routes/password/post.rs | 54 +++++++++++++++++ src/login/routes/password/test.rs | 68 ++++++++++++++++++++++ src/test/fixtures/identity.rs | 10 +++- src/token/app.rs | 43 +++++++++++++- src/token/repo/auth.rs | 29 +++++++++ src/token/repo/token.rs | 18 ++++++ src/token/secret.rs | 2 +- src/ui/routes/me.rs | 32 ++++++++++ src/ui/routes/mod.rs | 2 + ui/lib/apiServer.js | 4 ++ ui/lib/components/CurrentUser.svelte | 26 +++++++++ ui/lib/components/LogOut.svelte | 26 --------- ui/routes/(app)/+layout.svelte | 4 -- ui/routes/(app)/me/+page.svelte | 41 +++++++++++++ ui/routes/+layout.svelte | 8 +-- 23 files changed, 542 insertions(+), 40 deletions(-) create mode 100644 .sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json create mode 100644 .sqlx/query-0f3bfb1ad8fad5213f733b32d8eb2d9c2bb4de2fbbf0b2280973966ef02f72b1.json create mode 100644 .sqlx/query-c2b0ff7e2f27b6970a16fbc233ed32638e853e3b8b8f8de26b53f90c98b6ce11.json create mode 100644 src/login/routes/password/mod.rs create mode 100644 src/login/routes/password/post.rs create mode 100644 src/login/routes/password/test.rs create mode 100644 src/ui/routes/me.rs create mode 100644 ui/lib/components/CurrentUser.svelte delete mode 100644 ui/lib/components/LogOut.svelte create mode 100644 ui/routes/(app)/me/+page.svelte diff --git a/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json new file mode 100644 index 0000000..937b07e --- /dev/null +++ b/.sqlx/query-0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n delete\n from token\n where login = $1\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0b1543ec93e02c48c5cbaafd391b5812dc2d1d4a52ea3072b5dd52d71637b33d" +} diff --git a/.sqlx/query-0f3bfb1ad8fad5213f733b32d8eb2d9c2bb4de2fbbf0b2280973966ef02f72b1.json b/.sqlx/query-0f3bfb1ad8fad5213f733b32d8eb2d9c2bb4de2fbbf0b2280973966ef02f72b1.json new file mode 100644 index 0000000..ffd81dc --- /dev/null +++ b/.sqlx/query-0f3bfb1ad8fad5213f733b32d8eb2d9c2bb4de2fbbf0b2280973966ef02f72b1.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: login::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from login\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "password_hash: StoredHash", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0f3bfb1ad8fad5213f733b32d8eb2d9c2bb4de2fbbf0b2280973966ef02f72b1" +} diff --git a/.sqlx/query-c2b0ff7e2f27b6970a16fbc233ed32638e853e3b8b8f8de26b53f90c98b6ce11.json b/.sqlx/query-c2b0ff7e2f27b6970a16fbc233ed32638e853e3b8b8f8de26b53f90c98b6ce11.json new file mode 100644 index 0000000..4c99c42 --- /dev/null +++ b/.sqlx/query-c2b0ff7e2f27b6970a16fbc233ed32638e853e3b8b8f8de26b53f90c98b6ce11.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n update login\n set password_hash = $1\n where id = $2\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "c2b0ff7e2f27b6970a16fbc233ed32638e853e3b8b8f8de26b53f90c98b6ce11" +} diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 135e91b..93a8e52 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -113,3 +113,50 @@ The request must be an empty JSON object. This endpoint will respond with a status of `204 No Content` when successful. The response will include a `Set-Cookie` header that clears the `identity` cookie. Regardless of whether the client clears the cookie, the service also invalidates the token. + + +## `POST /api/password` + +Changes the current login's password, and invalidate all outstanding identity tokens. + +### Request + +```json +{ + "password": "my-old-password", + "to": "my-new-password" +} +``` + +The request must have the following fields: + +| Field | Type | Description | +|:-----------|:-------|:--| +| `password` | string | The login's _current_ password, in plain text. | +| `to` | string | The login's _new_ password, in plain text. | + +### Success + +This endpoint will respond with a status of `200 Okay` when successful. The body of the response will be a JSON object describing the authenticated login: + +```json +{ + "id": "Labcd1234", + "name": "Andrea" +} +``` + +The response will include the following fields: + +| Field | Type | Description | +|:------------|:-------|:--| +| `id` | string | The authenticated login's ID. | +| `name` | string | The authenticated login's name. | + +The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the login identified in the request. This token's value must be kept confidential. All previously-created identity cookies will cease to be valid. + +The cookie will expire if it is not used regularly. + +### Authentication failure + +This endpoint will respond with a status of `400 Bad Request` if the `password` does not match the login's current password. 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, 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 { 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, + RequestedAt(now): RequestedAt, + identity: Identity, + cookie: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Json), 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, 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, 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) -> Result { + 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 { .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)) diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index db554e2..19dcf60 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -30,6 +30,10 @@ export async function logOut() { return apiServer.post('/auth/logout', {}); } +export async function changePassword(password, to) { + return apiServer.post('/password', { password, to }); +} + export async function createChannel(name) { return apiServer.post('/channels', { name }); } diff --git a/ui/lib/components/CurrentUser.svelte b/ui/lib/components/CurrentUser.svelte new file mode 100644 index 0000000..4b1b974 --- /dev/null +++ b/ui/lib/components/CurrentUser.svelte @@ -0,0 +1,26 @@ + + +
+ {#if $currentUser} + @{$currentUser.username} + {/if} + +
+ + diff --git a/ui/lib/components/LogOut.svelte b/ui/lib/components/LogOut.svelte deleted file mode 100644 index ba0861a..0000000 --- a/ui/lib/components/LogOut.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
- {#if $currentUser} - @{$currentUser.username} - {/if} - -
- - diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 9abaaf4..08c6694 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -86,8 +86,4 @@ max-height: 100%; overflow: scroll; } - #interface .active-channel { - border: 1px solid grey; - border-radius: 1.25rem; - } diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte new file mode 100644 index 0000000..fb612b8 --- /dev/null +++ b/ui/routes/(app)/me/+page.svelte @@ -0,0 +1,41 @@ + + +
+ + + + + + + +
+ + diff --git a/ui/routes/+layout.svelte b/ui/routes/+layout.svelte index fdd3883..711b8bd 100644 --- a/ui/routes/+layout.svelte +++ b/ui/routes/+layout.svelte @@ -3,18 +3,16 @@ import "../app.css"; import { currentUser } from '$lib/store'; - import LogOut from '$lib/components/LogOut.svelte'; - import Invite from '$lib/components/Invite.svelte'; + import CurrentUser from '$lib/components/CurrentUser.svelte';
🌳 - understory + understory {#if $currentUser} - - + {/if} -- cgit v1.2.3 From cdb40ca671a85704f751179d13277a7db816b569 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 23:53:23 -0400 Subject: Incrementally less jank invite listing. --- ui/lib/components/Invite.svelte | 41 +++++++--------------------------------- ui/lib/components/Invites.svelte | 28 +++++++++++++++++++++++++++ ui/routes/(app)/me/+page.svelte | 4 ++-- 3 files changed, 37 insertions(+), 36 deletions(-) create mode 100644 ui/lib/components/Invites.svelte diff --git a/ui/lib/components/Invite.svelte b/ui/lib/components/Invite.svelte index f4babad..7fdc753 100644 --- a/ui/lib/components/Invite.svelte +++ b/ui/lib/components/Invite.svelte @@ -1,40 +1,13 @@ - async function onSubmit() { - let response = await createInvite(); - if (response.status == 200) { - invite = response.data; - } - console.log("base url", base); - } + +{inviteUrl} - async function onReset() { - invite = null; - } - -
- {#if inviteUrl} - - {:else} - - {/if} -
diff --git a/ui/lib/components/Invites.svelte b/ui/lib/components/Invites.svelte new file mode 100644 index 0000000..df51afb --- /dev/null +++ b/ui/lib/components/Invites.svelte @@ -0,0 +1,28 @@ + + +
    + {#each $invites as invite} +
  • + {/each} +
+
+ +
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte index fb612b8..7559dbe 100644 --- a/ui/routes/(app)/me/+page.svelte +++ b/ui/routes/(app)/me/+page.svelte @@ -1,7 +1,7 @@