diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2024-10-11 22:40:03 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2024-10-11 22:40:03 -0400 |
| commit | 812f1cafe3f8a68bf45b677fade7417c30d92eac (patch) | |
| tree | e2633472a31bd86bf08bcc16ddb4296df89064b4 | |
| parent | a0abed5ea08b2fc5b9ac4abdade1199f62cd5da7 (diff) | |
Create APIs for inviting users.
| -rw-r--r-- | .sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json | 32 | ||||
| -rw-r--r-- | .sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json | 12 | ||||
| -rw-r--r-- | .sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json | 20 | ||||
| -rw-r--r-- | .sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json | 32 | ||||
| -rw-r--r-- | .sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json | 32 | ||||
| -rw-r--r-- | docs/api.md | 76 | ||||
| -rw-r--r-- | migrations/20241012012022_invite.sql | 10 | ||||
| -rw-r--r-- | src/app.rs | 5 | ||||
| -rw-r--r-- | src/cli.rs | 3 | ||||
| -rw-r--r-- | src/expire.rs | 1 | ||||
| -rw-r--r-- | src/invite/app.rs | 106 | ||||
| -rw-r--r-- | src/invite/id.rs | 24 | ||||
| -rw-r--r-- | src/invite/mod.rs | 24 | ||||
| -rw-r--r-- | src/invite/repo.rs | 121 | ||||
| -rw-r--r-- | src/invite/routes.rs | 94 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/ui.rs | 14 |
17 files changed, 604 insertions, 3 deletions
diff --git a/.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json b/.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json new file mode 100644 index 0000000..c0791f7 --- /dev/null +++ b/.sqlx/query-693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tinsert into invite (id, issuer, issued_at)\n\t\t\t\tvalues ($1, $2, $3)\n\t\t\t\treturning\n\t\t\t\t\tid as \"id: Id\",\n\t\t\t\t\tissuer as \"issuer: login::Id\",\n\t\t\t\t\tissued_at as \"issued_at: DateTime\"\n\t\t\t", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "issuer: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issued_at: DateTime", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "693b6758b8e8a21f8e8017573526a2ef050389298851a2c0da9adec7d05fc862" +} diff --git a/.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json b/.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json new file mode 100644 index 0000000..f7c0552 --- /dev/null +++ b/.sqlx/query-736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tdelete from invite\n\t\t\t\twhere issued_at < $1\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "736bf4e90f23163d7d3a551d5a848b53bc8febe8d4417b5162f8ca330daac885" +} diff --git a/.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json b/.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json new file mode 100644 index 0000000..2004e8f --- /dev/null +++ b/.sqlx/query-d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tdelete from invite\n\t\t\t\twhere id = $1\n\t\t\t\treturning 1 as \"deleted: bool\"\n\t\t\t", + "describe": { + "columns": [ + { + "name": "deleted: bool", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + null + ] + }, + "hash": "d704486ba77fb40e4dd40ff96acc7783f88618a18797958d558ef6687dc29750" +} diff --git a/.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json b/.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json new file mode 100644 index 0000000..5a34da8 --- /dev/null +++ b/.sqlx/query-df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tselect\n\t\t\t\t\tissuer.id as \"issuer_id: login::Id\",\n\t\t\t\t\tissuer.name as \"issuer_name\",\n\t\t\t\t\tinvite.issued_at as \"invite_issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\tjoin login as issuer on (invite.issuer = issuer.id)\n\t\t\t\twhere invite.id = $1\n\t\t\t", + "describe": { + "columns": [ + { + "name": "issuer_id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "issuer_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "invite_issued_at: DateTime", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "df22d29fc47244ee696b3eaab9d1be6f740ec4a7fc713056befc4ac700259951" +} diff --git a/.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json b/.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json new file mode 100644 index 0000000..f38401c --- /dev/null +++ b/.sqlx/query-ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tselect\n\t\t\t\t\tid as \"id: Id\",\n\t\t\t\t\tissuer as \"issuer: login::Id\",\n\t\t\t\t\tissued_at as \"issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\twhere id = $1\n\t\t\t", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "issuer: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issued_at: DateTime", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "ec1bfce459faaf3d4adbed040105e9e7fa30a6aac5580036d052a1270e13a311" +} diff --git a/docs/api.md b/docs/api.md index 3545a46..f91780e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -88,7 +88,7 @@ If the token is not valid or has expired, then `hi` will send back a 401 Unautho ### `POST /api/auth/login` -Authenticates the user by login name and password, creating a login if none exists. **This endpoint does not require an `identity` cookie.** +Authenticates the user by login name and password. **This endpoint does not require an `identity` cookie.** #### Request @@ -123,6 +123,80 @@ Invalidates the identity token, logging the user out. This endpoint returns a 204 No Content response on success, with a `Set-Cookie` header that clears the `identity` cookie. The cookie provided in the request is also invalidated, and will not authenticate future requests even if the client fails to process the `Set-Cookie` response header. +## Invitations + +Other than the login created during initial setup, new logins can only be created by accepting _invitations_. An invitation can be used at most once, and expires after a day; to invite multiple people, create multiple invitations. + +### `POST /api/invite` + +Creates an invitation. + +#### Request + +```json +{} +``` + +#### On success + +This endpoint returns a new invitation each time it's called: + +```json +{ + "id": "I3884", + "issuer": "Labcd1234", + "issued_at": "2024-10-12T01:43:12.001853Z" +} +``` + +### `GET /api/invite/:invite` + +Returns information about a previously-created invite. **This endpoint does not require an `identity` cookie.** + +The `:invite` placeholder must be an invite ID, as returned by `POST /api/invite`. + +#### On success + +This endpoint returns the existing invitation: + +```json +{ + "issuer": { + "id": "Labcd1234", + "name": "i send you invites" + }, + "issued_at": "2024-10-12T01:43:12.001853Z" +} +``` + +#### Invite not found + +This endpoint returns a 404 Not Found response if the invite ID in the path does not exist, or has already been accepted. + + +### `POST /api/invite/:invite` + +Accepts an invitation and creates a new login. **This endpoint does not require an `identity` cookie.** + +The `:invite` placeholder must be an invite ID, as returned by `POST /api/invite`. + +#### Request + +```json +{ + "name": "example login", + "password": "correct-horse-battery-staple" +} +``` + +#### On success + +This endpoint returns a 204 No Content response on success, with a `Set-Cookie` header setting the `identity` cookie to a newly created token for this login. See the [Authentication](#Authentication) section for the details of this cookie. The invite is consumed, and cannot be accepted a second time. + +#### Invite not found + +This endpoint returns a 404 Not Found response if the invite ID in the path does not exist, or has already been accepted. + ## Working with channels Channels are the containers for conversations. The API supports listing channels, creating new channels, and send messages to an existing channel. diff --git a/migrations/20241012012022_invite.sql b/migrations/20241012012022_invite.sql new file mode 100644 index 0000000..13004b5 --- /dev/null +++ b/migrations/20241012012022_invite.sql @@ -0,0 +1,10 @@ +create table invite ( + id text + primary key + not null, + issuer text + not null + references login (id), + issued_at text + not null +); @@ -4,6 +4,7 @@ use crate::{ boot::app::Boot, channel::app::Channels, event::{self, app::Events}, + invite::app::Invites, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, @@ -44,6 +45,10 @@ impl App { Events::new(&self.db, &self.events) } + pub const fn invites(&self) -> Invites { + Invites::new(&self.db) + } + #[cfg(test)] pub const fn logins(&self) -> Logins { Logins::new(&self.db, &self.events) @@ -17,7 +17,7 @@ use tokio::net; use crate::{ app::App, - boot, channel, clock, db, event, expire, login, message, + boot, channel, clock, db, event, expire, invite, login, message, setup::{self, middleware::setup_required}, ui, }; @@ -138,6 +138,7 @@ fn routers(app: &App) -> Router<App> { boot::router(), channel::router(), event::router(), + invite::router(), login::router(), message::router(), ] diff --git a/src/expire.rs b/src/expire.rs index e50bcb4..eaedc44 100644 --- a/src/expire.rs +++ b/src/expire.rs @@ -14,6 +14,7 @@ pub async fn middleware( next: Next, ) -> Result<Response, Internal> { app.tokens().expire(&expired_at).await?; + app.invites().expire(&expired_at).await?; app.messages().expire(&expired_at).await?; app.channels().expire(&expired_at).await?; Ok(next.run(req).await) diff --git a/src/invite/app.rs b/src/invite/app.rs new file mode 100644 index 0000000..998b4f1 --- /dev/null +++ b/src/invite/app.rs @@ -0,0 +1,106 @@ +use chrono::TimeDelta; +use sqlx::sqlite::SqlitePool; + +use super::{repo::Provider as _, Id, Invite, Summary}; +use crate::{ + clock::DateTime, + db::NotFound as _, + event::repo::Provider as _, + login::{repo::Provider as _, Login, Password}, + token::{repo::Provider as _, Secret}, +}; + +pub struct Invites<'a> { + db: &'a SqlitePool, +} + +impl<'a> Invites<'a> { + pub const fn new(db: &'a SqlitePool) -> Self { + Self { db } + } + + pub async fn create( + &self, + issuer: &Login, + issued_at: &DateTime, + ) -> Result<Invite, sqlx::Error> { + let mut tx = self.db.begin().await?; + let invite = tx.invites().create(issuer, issued_at).await?; + tx.commit().await?; + + Ok(invite) + } + + pub async fn get(&self, invite: &Id) -> Result<Summary, Error> { + let mut tx = self.db.begin().await?; + let invite = tx + .invites() + .summary(invite) + .await + .not_found(|| Error::NotFound(invite.clone()))?; + tx.commit().await?; + + Ok(invite) + } + + pub async fn accept( + &self, + invite: &Id, + name: &str, + password: &Password, + accepted_at: &DateTime, + ) -> Result<Secret, AcceptError> { + let mut tx = self.db.begin().await?; + let invite = tx + .invites() + .by_id(invite) + .await + .not_found(|| AcceptError::NotFound(invite.clone()))?; + // Split the tx here so we don't block writes while we deal with the password, + // and don't deal with the password until we're fairly confident we can accept + // the invite. Final validation is in the next tx. + tx.commit().await?; + + let password_hash = password.hash()?; + + 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).await?; + let secret = tx.tokens().issue(&login, accepted_at).await?; + tx.commit().await?; + + Ok(secret) + } + + pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + // Somewhat arbitrarily, expire after one day. + let expire_at = relative_to.to_owned() - TimeDelta::days(1); + + let mut tx = self.db.begin().await?; + tx.invites().expire(&expire_at).await?; + tx.commit().await?; + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum AcceptError { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} diff --git a/src/invite/id.rs b/src/invite/id.rs new file mode 100644 index 0000000..bd53f1f --- /dev/null +++ b/src/invite/id.rs @@ -0,0 +1,24 @@ +use crate::id::Id as BaseId; + +// Stable identifier for an invite Prefixed with `I`. +#[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, serde::Deserialize, serde::Serialize)] +#[sqlx(transparent)] +pub struct Id(BaseId); + +impl From<BaseId> for Id { + fn from(id: BaseId) -> Self { + Self(id) + } +} + +impl Id { + pub fn generate() -> Self { + BaseId::generate("I") + } +} + +impl std::fmt::Display for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/src/invite/mod.rs b/src/invite/mod.rs new file mode 100644 index 0000000..5f9d490 --- /dev/null +++ b/src/invite/mod.rs @@ -0,0 +1,24 @@ +pub mod app; +mod id; +mod repo; +mod routes; + +use crate::{ + clock::DateTime, + login::{self, Login}, +}; + +pub use self::{id::Id, routes::router}; + +#[derive(Debug, serde::Serialize)] +pub struct Invite { + pub id: Id, + pub issuer: login::Id, + pub issued_at: DateTime, +} + +#[derive(serde::Serialize)] +pub struct Summary { + pub issuer: Login, + pub issued_at: DateTime, +} diff --git a/src/invite/repo.rs b/src/invite/repo.rs new file mode 100644 index 0000000..2ab993f --- /dev/null +++ b/src/invite/repo.rs @@ -0,0 +1,121 @@ +use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; + +use super::{Id, Invite, Summary}; +use crate::{ + clock::DateTime, + login::{self, Login}, +}; + +pub trait Provider { + fn invites(&mut self) -> Invites; +} + +impl<'c> Provider for Transaction<'c, Sqlite> { + fn invites(&mut self) -> Invites { + Invites(self) + } +} + +pub struct Invites<'t>(&'t mut SqliteConnection); + +impl<'c> Invites<'c> { + pub async fn create( + &mut self, + issuer: &Login, + issued_at: &DateTime, + ) -> Result<Invite, sqlx::Error> { + let id = Id::generate(); + let invite = sqlx::query_as!( + Invite, + r#" + insert into invite (id, issuer, issued_at) + values ($1, $2, $3) + returning + id as "id: Id", + issuer as "issuer: login::Id", + issued_at as "issued_at: DateTime" + "#, + id, + issuer.id, + issued_at + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(invite) + } + + pub async fn by_id(&mut self, invite: &Id) -> Result<Invite, sqlx::Error> { + let invite = sqlx::query_as!( + Invite, + r#" + select + id as "id: Id", + issuer as "issuer: login::Id", + issued_at as "issued_at: DateTime" + from invite + where id = $1 + "#, + invite, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(invite) + } + + pub async fn summary(&mut self, invite: &Id) -> Result<Summary, sqlx::Error> { + let invite = sqlx::query!( + r#" + select + issuer.id as "issuer_id: login::Id", + issuer.name as "issuer_name", + invite.issued_at as "invite_issued_at: DateTime" + from invite + join login as issuer on (invite.issuer = issuer.id) + where invite.id = $1 + "#, + invite, + ) + .map(|row| Summary { + issuer: Login { + id: row.issuer_id, + name: row.issuer_name, + }, + issued_at: row.invite_issued_at, + }) + .fetch_one(&mut *self.0) + .await?; + + Ok(invite) + } + + pub async fn accept(&mut self, invite: &Invite) -> Result<(), sqlx::Error> { + sqlx::query_scalar!( + r#" + delete from invite + where id = $1 + returning 1 as "deleted: bool" + "#, + invite.id, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(()) + } + + pub async fn expire(&mut self, expire_at: &DateTime) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + delete from invite + where issued_at < $1 + "#, + expire_at, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } +} diff --git a/src/invite/routes.rs b/src/invite/routes.rs new file mode 100644 index 0000000..3384e10 --- /dev/null +++ b/src/invite/routes.rs @@ -0,0 +1,94 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; + +use super::{app, Id, Invite, Summary}; +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + login::{Login, Password}, + token::extract::IdentityToken, +}; + +pub fn router() -> Router<App> { + Router::new() + .route("/api/invite", post(on_invite)) + .route("/api/invite/:invite", get(invite)) + .route("/api/invite/:invite", post(on_accept)) +} + +#[derive(serde::Deserialize)] +struct InviteRequest {} + +async fn on_invite( + State(app): State<App>, + RequestedAt(issued_at): RequestedAt, + login: Login, + // Require `{}` as the only valid request for this endpoint. + _: Json<InviteRequest>, +) -> Result<Json<Invite>, Internal> { + let invite = app.invites().create(&login, &issued_at).await?; + Ok(Json(invite)) +} + +async fn invite( + State(app): State<App>, + Path(invite): Path<Id>, +) -> Result<Json<Summary>, InviteError> { + app.invites() + .get(&invite) + .await + .map(Json) + .map_err(InviteError) +} + +struct InviteError(app::Error); + +impl IntoResponse for InviteError { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + error @ app::Error::NotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} + +#[derive(serde::Deserialize)] +struct AcceptRequest { + name: String, + password: Password, +} + +async fn on_accept( + State(app): State<App>, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityToken, + Path(invite): Path<Id>, + Json(request): Json<AcceptRequest>, +) -> Result<(IdentityToken, StatusCode), AcceptError> { + let secret = app + .invites() + .accept(&invite, &request.name, &request.password, &accepted_at) + .await + .map_err(AcceptError)?; + let identity = identity.set(secret); + Ok((identity, StatusCode::NO_CONTENT)) +} + +struct AcceptError(app::AcceptError); + +impl IntoResponse for AcceptError { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + error @ app::AcceptError::NotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} @@ -13,6 +13,7 @@ mod error; mod event; mod expire; mod id; +mod invite; mod login; mod message; mod setup; @@ -9,7 +9,7 @@ use axum::{ use mime_guess::Mime; use rust_embed::EmbeddedFile; -use crate::{app::App, channel, error::Internal, login::Login}; +use crate::{app::App, channel, error::Internal, invite, login::Login}; #[derive(rust_embed::Embed)] #[folder = "target/ui"] @@ -41,6 +41,7 @@ pub fn router(app: &App) -> Router<App> { .route("/", get(root)) .route("/login", get(login)) .route("/ch/:channel", get(channel)) + .route("/invite/:invite", get(invite)) .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), ] .into_iter() @@ -85,6 +86,17 @@ async fn channel( } } +async fn invite( + State(app): State<App>, + Path(invite): Path<invite::Id>, +) -> Result<impl IntoResponse, Internal> { + match app.invites().get(&invite).await { + Ok(_) => Ok(Assets::index()?.into_response()), + Err(invite::app::Error::NotFound(_)) => Ok(NotFound(Assets::index()?).into_response()), + Err(other) => Err(Internal::from(other)), + } +} + struct Asset(Mime, EmbeddedFile); impl IntoResponse for Asset { |
