diff options
| author | ojacobson <ojacobson@noreply.codeberg.org> | 2025-08-26 23:36:40 +0200 |
|---|---|---|
| committer | ojacobson <ojacobson@noreply.codeberg.org> | 2025-08-26 23:36:40 +0200 |
| commit | 7b131e35fdea1a68aaf9230d157bafb200557ef8 (patch) | |
| tree | b0f3ee3ac604947a8866c692a080d3f6064d7d03 /src/token | |
| parent | 68f54c8904ec6ff2ac3be4c514fa4aa05a67cb68 (diff) | |
| parent | d0d5fa20200a7ad70173ba87ae47c33b60f44a3b (diff) | |
Split `user` into a chat-facing entity and an authentication-facing entity.
The taxonomy is now as follows:
* A _login_ is someone's identity for the purposes of authenticating to the service. Logins are not synchronized, and in fact are not published anywhere in the current API. They have a login ID, a name and a password.
* A _user_ is someone's identity for the purpose of participating in conversations. Users _are_ synchronized, as before. They have a user ID, a name, and a creation instant for the purposes of synchronization.
## API changes
* `GET /api/boot` method now returns a `login` key instead of a `user` key. The structure of the nested value is unchanged. This change is not backwards-compatible; the included client and the docs have been updated accordingly.
## Server implementation
* Most app methods that took a `&User` as an identity now take a `&Login` as an identity, instead. Where a `User` is needed, the new `tx.users().for_login(&login)` database access method resolves a `Login` to its corresponding `user::History`, which can then be turned into a `User` at whatever point in time is most appropriate.
This adds a few new error cases to methods that traverse the login-to-history-to-user chain. Those cases are presently unreachable, but I've fully fleshed them out so that they don't bite us later. Most of the resulting errors, however, are captured as internal server errors.
* There is a new `app.logins()` application entry point, dealing with login identities and password-based logins.
* `app.tokens()` is a bit more limited in scope to only things that work with an existing token.
That has the side effect of splitting up logging in (in `app.logins().with_password(…)`) and logging out (in `app.tokens().logout(…)`).
## Schema changes
The `user` table has been split:
* `login` holds the data needed for the user to log in - their login ID, their name, and their password.
* `user` now holds only the user ID and the event data for the user's `created` instant. Reconstructing a `User` struct requires joining in data from both `login` and `user`.
In theory, the relationship is one-way: every user has a login. In practice, it's reciprocal: every login has a user and every user has a login.
Relationships with downstream tables have been modified to suit:
* `message` still refers to `user` for authorship information.
* `invite` still refers to `user` for originator information.
* `token` refers to `login` for authentication information.
## Blimy, that's big
Yeah, I know. It's hard to avoid and I'm not sure the effort of making this in incremental steps is worth it.
Authentication logic has a way of getting into all sorts of corners, and Pilcrow is no different. In order for the new taxonomy to make sense, all of the places that previously used `User` as a representation of an authenticated identity have to be updated, and it's easier to do that all at once, so that we can retire all the code that _supports_ using a `User` that way.
Merges split-user into main.
Diffstat (limited to 'src/token')
| -rw-r--r-- | src/token/app.rs | 126 | ||||
| -rw-r--r-- | src/token/extract/identity.rs | 20 | ||||
| -rw-r--r-- | src/token/mod.rs | 28 | ||||
| -rw-r--r-- | src/token/repo/auth.rs | 103 | ||||
| -rw-r--r-- | src/token/repo/mod.rs | 1 | ||||
| -rw-r--r-- | src/token/repo/token.rs | 97 |
6 files changed, 91 insertions, 284 deletions
diff --git a/src/token/app.rs b/src/token/app.rs index 56c0e21..fb5d712 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -6,16 +6,11 @@ use futures::{ use sqlx::sqlite::SqlitePool; use super::{ - Broadcaster, Event as TokenEvent, Id, Secret, - repo::{self, Provider as _, auth::Provider as _}, -}; -use crate::{ - clock::DateTime, - db::NotFound as _, - name::{self, Name}, - password::Password, - user::{User, repo::Provider as _}, + Broadcaster, Event as TokenEvent, Secret, Token, + extract::Identity, + repo::{self, Provider as _}, }; +use crate::{clock::DateTime, db::NotFound as _, name}; pub struct Tokens<'a> { db: &'a SqlitePool, @@ -27,106 +22,33 @@ impl<'a> Tokens<'a> { Self { db, token_events } } - pub async fn login( - &self, - name: &Name, - password: &Password, - login_at: &DateTime, - ) -> Result<Secret, LoginError> { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_name(name) - .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?; - - user.as_snapshot().ok_or(LoginError::Rejected)?; - - if stored_hash.verify(password)? { - let mut tx = self.db.begin().await?; - let secret = tx.tokens().issue(&user, login_at).await?; - tx.commit().await?; - Ok(secret) - } else { - Err(LoginError::Rejected) - } - } - - pub async fn change_password( - &self, - user: &User, - password: &Password, - to: &Password, - changed_at: &DateTime, - ) -> Result<Secret, LoginError> { - let mut tx = self.db.begin().await?; - let (user, stored_hash) = tx - .auth() - .for_user(user) - .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); - } - - user.as_snapshot().ok_or(LoginError::Rejected)?; - let to_hash = to.hash()?; - - let mut tx = self.db.begin().await?; - let tokens = tx.tokens().revoke_all(&user).await?; - tx.users().set_password(&user, &to_hash).await?; - let secret = tx.tokens().issue(&user, changed_at).await?; - tx.commit().await?; - - for event in tokens.into_iter().map(TokenEvent::Revoked) { - self.token_events.broadcast(event); - } - - Ok(secret) - } - pub async fn validate( &self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Id, User), ValidateError> { + ) -> Result<Identity, ValidateError> { let mut tx = self.db.begin().await?; - let (token, user) = tx + let (token, login) = tx .tokens() .validate(secret, used_at) .await .not_found(|| ValidateError::InvalidToken)?; tx.commit().await?; - let user = user.as_snapshot().ok_or(ValidateError::LoginDeleted)?; - - Ok((token, user)) + Ok(Identity { token, login }) } pub async fn limit_stream<S, E>( &self, - token: Id, + token: &Token, events: S, ) -> Result<impl Stream<Item = E> + std::fmt::Debug + use<S, E>, ValidateError> where S: Stream<Item = E> + std::fmt::Debug, E: std::fmt::Debug, { + let token = token.id.clone(); + // Subscribe, first. let token_events = self.token_events.subscribe(); @@ -187,46 +109,22 @@ impl<'a> Tokens<'a> { Ok(()) } - pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> { + pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> { let mut tx = self.db.begin().await?; tx.tokens().revoke(token).await?; tx.commit().await?; self.token_events - .broadcast(TokenEvent::Revoked(token.clone())); + .broadcast(TokenEvent::Revoked(token.id.clone())); Ok(()) } } #[derive(Debug, thiserror::Error)] -pub enum LoginError { - #[error("invalid login")] - Rejected, - #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] - Name(#[from] name::Error), - #[error(transparent)] - PasswordHash(#[from] password_hash::Error), -} - -impl From<repo::auth::LoadError> for LoginError { - fn from(error: repo::auth::LoadError) -> Self { - use repo::auth::LoadError; - match error { - LoadError::Database(error) => error.into(), - LoadError::Name(error) => error.into(), - } - } -} - -#[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] InvalidToken, - #[error("user deleted")] - LoginDeleted, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index d1c0334..960fe60 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -10,14 +10,14 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, Unauthorized}, - token::{self, app::ValidateError}, - user::User, + login::Login, + token::{Token, app::ValidateError}, }; #[derive(Clone, Debug)] pub struct Identity { - pub token: token::Id, - pub user: User, + pub token: Token, + pub login: Login, } impl FromRequestParts<App> for Identity { @@ -30,11 +30,13 @@ impl FromRequestParts<App> for Identity { let secret = cookie.secret().ok_or(LoginError::Unauthorized)?; let app = State::<App>::from_request_parts(parts, state).await?; - match app.tokens().validate(&secret, &used_at).await { - Ok((token, user)) => Ok(Identity { token, user }), - Err(ValidateError::InvalidToken) => Err(LoginError::Unauthorized), - Err(other) => Err(other.into()), - } + app.tokens() + .validate(&secret, &used_at) + .await + .map_err(|err| match err { + ValidateError::InvalidToken => LoginError::Unauthorized, + other => other.into(), + }) } } diff --git a/src/token/mod.rs b/src/token/mod.rs index 33403ef..b2dd6f1 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -6,4 +6,32 @@ mod id; pub mod repo; mod secret; +use uuid::Uuid; + pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret}; +use crate::{clock::DateTime, login, login::Login}; + +#[derive(Clone, Debug)] +pub struct Token { + pub id: Id, + pub login: login::Id, + pub issued_at: DateTime, + pub last_used_at: DateTime, +} + +impl Token { + pub fn generate(login: &Login, issued_at: &DateTime) -> (Self, Secret) { + let id = Id::generate(); + let secret = Uuid::new_v4().to_string().into(); + + ( + Self { + id, + login: login.id.clone(), + issued_at: *issued_at, + last_used_at: *issued_at, + }, + secret, + ) + } +} diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs deleted file mode 100644 index 600855d..0000000 --- a/src/token/repo/auth.rs +++ /dev/null @@ -1,103 +0,0 @@ -use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; - -use crate::{ - clock::DateTime, - db::NotFound, - event::{Instant, Sequence}, - name::{self, Name}, - password::StoredHash, - user::{self, History, User}, -}; - -pub trait Provider { - fn auth(&mut self) -> Auth<'_>; -} - -impl Provider for Transaction<'_, Sqlite> { - fn auth(&mut self) -> Auth<'_> { - Auth(self) - } -} - -pub struct Auth<'t>(&'t mut SqliteConnection); - -impl Auth<'_> { - pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), LoadError> { - let name = name.canonical(); - let row = sqlx::query!( - r#" - select - id as "id: user::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 user - where canonical_name = $1 - "#, - name, - ) - .fetch_one(&mut *self.0) - .await?; - - let login = History { - user: User { - 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)) - } - - pub async fn for_user(&mut self, user: &User) -> Result<(History, StoredHash), LoadError> { - let row = sqlx::query!( - r#" - select - id as "id: user::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 user - where id = $1 - "#, - user.id, - ) - .fetch_one(&mut *self.0) - .await?; - - let user = History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), - }; - - Ok((user, row.password_hash)) - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum LoadError { - Database(#[from] sqlx::Error), - Name(#[from] name::Error), -} - -impl<T> NotFound for Result<T, LoadError> { - type Ok = T; - type Error = LoadError; - - fn optional(self) -> Result<Option<T>, LoadError> { - match self { - Ok(value) => Ok(Some(value)), - Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), - Err(other) => Err(other), - } - } -} diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs index d8463eb..9df5bbb 100644 --- a/src/token/repo/mod.rs +++ b/src/token/repo/mod.rs @@ -1,4 +1,3 @@ -pub mod auth; mod token; pub use self::token::{LoadError, Provider}; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index 7ac4ac5..52a3987 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -1,13 +1,11 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; -use uuid::Uuid; use crate::{ clock::DateTime, db::NotFound, - event::{Instant, Sequence}, + login::{self, Login}, name::{self, Name}, - token::{Id, Secret}, - user::{self, History, User}, + token::{Id, Secret, Token}, }; pub trait Provider { @@ -23,33 +21,23 @@ impl Provider for Transaction<'_, Sqlite> { pub struct Tokens<'t>(&'t mut SqliteConnection); impl Tokens<'_> { - // Issue a new token for an existing user. The issued_at timestamp will - // determine the token's initial expiry deadline. - pub async fn issue( - &mut self, - user: &History, - issued_at: &DateTime, - ) -> Result<Secret, sqlx::Error> { - let id = Id::generate(); - let secret = Uuid::new_v4().to_string(); - let user = user.id(); - - let secret = sqlx::query_scalar!( + pub async fn create(&mut self, token: &Token, secret: &Secret) -> Result<(), sqlx::Error> { + sqlx::query!( r#" insert - into token (id, secret, user, issued_at, last_used_at) - values ($1, $2, $3, $4, $4) - returning secret as "secret!: Secret" + into token (id, secret, login, issued_at, last_used_at) + values ($1, $2, $3, $4, $5) "#, - id, + token.id, secret, - user, - issued_at, + token.login, + token.issued_at, + token.last_used_at, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; - Ok(secret) + Ok(()) } pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> { @@ -67,34 +55,30 @@ impl Tokens<'_> { Ok(()) } - // Revoke a token by its secret. - pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> { - sqlx::query_scalar!( + pub async fn revoke(&mut self, token: &Token) -> Result<(), sqlx::Error> { + sqlx::query!( r#" - delete - from token + delete from token where id = $1 - returning id as "id: Id" "#, - token, + token.id, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; Ok(()) } // Revoke tokens for a login - pub async fn revoke_all(&mut self, user: &user::History) -> Result<Vec<Id>, sqlx::Error> { - let user = user.id(); + pub async fn revoke_all(&mut self, login: &Login) -> Result<Vec<Id>, sqlx::Error> { let tokens = sqlx::query_scalar!( r#" delete from token - where user = $1 + where login = $1 returning id as "id: Id" "#, - user, + login.id, ) .fetch_all(&mut *self.0) .await?; @@ -120,54 +104,53 @@ impl Tokens<'_> { Ok(tokens) } - // Validate a token by its secret, retrieving the associated Login record. - // Will return an error if the token is not valid. If successful, the - // retrieved token's last-used timestamp will be set to `used_at`. pub async fn validate( &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Id, History), LoadError> { + ) -> Result<(Token, Login), LoadError> { // I would use `update … returning` to do this in one query, but // sqlite3, as of this writing, does not allow an update's `returning` // clause to reference columns from tables joined into the update. Two // queries is fine, but it feels untidy. - let (token, user) = sqlx::query!( + let token = sqlx::query!( r#" update token set last_used_at = $1 where secret = $2 returning - id as "token: Id", - user as "user: user::Id" + id as "id: Id", + login as "login: login::Id", + issued_at as "issued_at: DateTime", + last_used_at as "last_used_at: DateTime" "#, used_at, secret, ) - .map(|row| (row.token, row.user)) + .map(|row| Token { + id: row.id, + login: row.login, + issued_at: row.issued_at, + last_used_at: row.last_used_at, + }) .fetch_one(&mut *self.0) .await?; let user = sqlx::query!( r#" select - id as "id: user::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" - from user + id as "id: login::Id", + display_name, + canonical_name + from login where id = $1 "#, - user, + token.login, ) .map(|row| { - Ok::<_, name::Error>(History { - user: User { - id: row.id, - name: Name::new(row.display_name, row.canonical_name)?, - }, - created: Instant::new(row.created_at, row.created_sequence), + Ok::<_, name::Error>(Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, }) }) .fetch_one(&mut *self.0) |
