summaryrefslogtreecommitdiff
path: root/src/user
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-08-26 00:44:29 -0400
committerOwen Jacobson <owen@grimoire.ca>2025-08-26 01:38:21 -0400
commitd0d5fa20200a7ad70173ba87ae47c33b60f44a3b (patch)
tree39d5dbd0cae7df293a87bc9fcfc78f57e72d5204 /src/user
parent0bbc83f09cc7517dddf16770a15f9e90815f48ba (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. In practice, a user exists for every login - in fact, users' names are stored in the login table and are joined in, rather than being stored redundantly in the user table. A login ID and its corresponding user ID are always equal, and the user and login ID types support conversion and comparison to facilitate their use in this context. Tokens are now associated with logins, not users. The currently-acting identity is passed down into app types as a login, not a user, and then resolved to a user where appropriate within the app methods. As a side effect, the `GET /api/boot` method now returns a `login` key instead of a `user` key. The structure of the nested value is unchanged.
Diffstat (limited to 'src/user')
-rw-r--r--src/user/app.rs14
-rw-r--r--src/user/create.rs28
-rw-r--r--src/user/handlers/login/mod.rs56
-rw-r--r--src/user/handlers/login/test.rs110
-rw-r--r--src/user/handlers/logout/mod.rs53
-rw-r--r--src/user/handlers/logout/test.rs72
-rw-r--r--src/user/handlers/mod.rs7
-rw-r--r--src/user/handlers/password/mod.rs58
-rw-r--r--src/user/handlers/password/test.rs47
-rw-r--r--src/user/history.rs26
-rw-r--r--src/user/id.rs17
-rw-r--r--src/user/mod.rs3
-rw-r--r--src/user/repo.rs89
13 files changed, 95 insertions, 485 deletions
diff --git a/src/user/app.rs b/src/user/app.rs
index 301c39c..0d6046c 100644
--- a/src/user/app.rs
+++ b/src/user/app.rs
@@ -1,10 +1,7 @@
use sqlx::sqlite::SqlitePool;
-use super::{
- User,
- create::{self, Create},
-};
-use crate::{clock::DateTime, event::Broadcaster, name::Name, password::Password};
+use super::create::{self, Create};
+use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password};
pub struct Users<'a> {
db: &'a SqlitePool,
@@ -21,7 +18,7 @@ impl<'a> Users<'a> {
name: &Name,
password: &Password,
created_at: &DateTime,
- ) -> Result<User, CreateError> {
+ ) -> Result<Login, CreateError> {
let create = Create::begin(name, password, created_at);
let validated = create.validate()?;
@@ -29,10 +26,10 @@ impl<'a> Users<'a> {
let stored = validated.store(&mut tx).await?;
tx.commit().await?;
- let user = stored.user().to_owned();
+ let login = stored.login().to_owned();
stored.publish(self.events);
- Ok(user.as_created())
+ Ok(login)
}
}
@@ -46,7 +43,6 @@ pub enum CreateError {
Database(#[from] sqlx::Error),
}
-#[cfg(test)]
impl From<create::Error> for CreateError {
fn from(error: create::Error) -> Self {
match error {
diff --git a/src/user/create.rs b/src/user/create.rs
index 5d7bf65..5c060c9 100644
--- a/src/user/create.rs
+++ b/src/user/create.rs
@@ -4,6 +4,7 @@ use super::{History, repo::Provider as _, validate};
use crate::{
clock::DateTime,
event::{Broadcaster, Event, repo::Provider as _},
+ login::{self, Login, repo::Provider as _},
name::Name,
password::{Password, StoredHash},
};
@@ -39,7 +40,7 @@ impl<'a> Create<'a> {
Ok(Validated {
name,
- password_hash,
+ password: password_hash,
created_at,
})
}
@@ -48,7 +49,7 @@ impl<'a> Create<'a> {
#[must_use = "dropping a user creation attempt is likely a mistake"]
pub struct Validated<'a> {
name: &'a Name,
- password_hash: StoredHash,
+ password: StoredHash,
created_at: &'a DateTime,
}
@@ -56,31 +57,38 @@ impl Validated<'_> {
pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result<Stored, sqlx::Error> {
let Self {
name,
- password_hash,
+ password,
created_at,
} = self;
+ let login = Login {
+ id: login::Id::generate(),
+ name: name.to_owned(),
+ };
+
let created = tx.sequence().next(created_at).await?;
- let user = tx.users().create(name, &password_hash, &created).await?;
+ tx.logins().create(&login, &password).await?;
+ let user = tx.users().create(&login, &created).await?;
- Ok(Stored { user })
+ Ok(Stored { user, login })
}
}
#[must_use = "dropping a user creation attempt is likely a mistake"]
pub struct Stored {
user: History,
+ login: Login,
}
impl Stored {
- pub fn publish(self, events: &Broadcaster) {
- let Self { user } = self;
+ pub fn publish(self, broadcaster: &Broadcaster) {
+ let Self { user, login: _ } = self;
- events.broadcast(user.events().map(Event::from).collect::<Vec<_>>());
+ broadcaster.broadcast(user.events().map(Event::from).collect::<Vec<_>>());
}
- pub fn user(&self) -> &History {
- &self.user
+ pub fn login(&self) -> &Login {
+ &self.login
}
}
diff --git a/src/user/handlers/login/mod.rs b/src/user/handlers/login/mod.rs
deleted file mode 100644
index d3e0e8c..0000000
--- a/src/user/handlers/login/mod.rs
+++ /dev/null
@@ -1,56 +0,0 @@
-use axum::{
- extract::{Json, State},
- http::StatusCode,
- response::{IntoResponse, Response},
-};
-
-use crate::{
- app::App,
- clock::RequestedAt,
- empty::Empty,
- error::Internal,
- name::Name,
- password::Password,
- token::{app, extract::IdentityCookie},
-};
-
-#[cfg(test)]
-mod test;
-
-pub async fn handler(
- State(app): State<App>,
- RequestedAt(now): RequestedAt,
- identity: IdentityCookie,
- Json(request): Json<Request>,
-) -> Result<(IdentityCookie, Empty), Error> {
- let secret = app
- .tokens()
- .login(&request.name, &request.password, &now)
- .await
- .map_err(Error)?;
- let identity = identity.set(secret);
- Ok((identity, Empty))
-}
-
-#[derive(serde::Deserialize)]
-pub struct Request {
- pub name: Name,
- pub password: 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 => {
- // not error::Unauthorized due to differing messaging
- (StatusCode::UNAUTHORIZED, "invalid name or password").into_response()
- }
- other => Internal::from(other).into_response(),
- }
- }
-}
diff --git a/src/user/handlers/login/test.rs b/src/user/handlers/login/test.rs
deleted file mode 100644
index 56fc2c4..0000000
--- a/src/user/handlers/login/test.rs
+++ /dev/null
@@ -1,110 +0,0 @@
-use axum::extract::{Json, State};
-
-use crate::{
- empty::Empty,
- test::{fixtures, verify},
- token::app,
-};
-
-#[tokio::test]
-async fn correct_credentials() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
- let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await;
-
- // Call the endpoint
-
- let identity = fixtures::cookie::not_logged_in();
- let logged_in_at = fixtures::now();
- let request = super::Request {
- name: name.clone(),
- password,
- };
- let (identity, Empty) =
- super::handler(State(app.clone()), logged_in_at, identity, Json(request))
- .await
- .expect("logged in with valid credentials");
-
- // Verify the return value's basic structure
-
- verify::identity::valid_for_name(&app, &identity, &name).await;
-}
-
-#[tokio::test]
-async fn invalid_name() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
-
- // Call the endpoint
-
- let identity = fixtures::cookie::not_logged_in();
- let logged_in_at = fixtures::now();
- let (name, password) = fixtures::user::propose();
- let request = super::Request {
- name: name.clone(),
- password,
- };
- let super::Error(error) =
- super::handler(State(app.clone()), logged_in_at, identity, Json(request))
- .await
- .expect_err("logged in with an incorrect password fails");
-
- // Verify the return value's basic structure
-
- assert!(matches!(error, app::LoginError::Rejected));
-}
-
-#[tokio::test]
-async fn incorrect_password() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
- let login = fixtures::user::create(&app, &fixtures::now()).await;
-
- // Call the endpoint
-
- let logged_in_at = fixtures::now();
- let identity = fixtures::cookie::not_logged_in();
- let request = super::Request {
- name: login.name,
- password: fixtures::user::propose_password(),
- };
- let super::Error(error) =
- super::handler(State(app.clone()), logged_in_at, identity, Json(request))
- .await
- .expect_err("logged in with an incorrect password");
-
- // Verify the return value's basic structure
-
- assert!(matches!(error, app::LoginError::Rejected));
-}
-
-#[tokio::test]
-async fn token_expires() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
- let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await;
-
- // Call the endpoint
-
- let logged_in_at = fixtures::ancient();
- let identity = fixtures::cookie::not_logged_in();
- let request = super::Request { name, password };
- let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request))
- .await
- .expect("logged in with valid credentials");
- let secret = identity.secret().expect("logged in with valid credentials");
-
- // Verify the semantics
-
- let expired_at = fixtures::now();
- app.tokens()
- .expire(&expired_at)
- .await
- .expect("expiring tokens never fails");
-
- verify::token::invalid(&app, &secret).await;
-}
diff --git a/src/user/handlers/logout/mod.rs b/src/user/handlers/logout/mod.rs
deleted file mode 100644
index f759451..0000000
--- a/src/user/handlers/logout/mod.rs
+++ /dev/null
@@ -1,53 +0,0 @@
-use axum::{
- extract::{Json, State},
- response::{IntoResponse, Response},
-};
-
-use crate::{
- app::App,
- clock::RequestedAt,
- empty::Empty,
- error::{Internal, Unauthorized},
- token::{app, extract::IdentityCookie},
-};
-
-#[cfg(test)]
-mod test;
-
-pub async fn handler(
- State(app): State<App>,
- RequestedAt(now): RequestedAt,
- identity: IdentityCookie,
- Json(_): Json<Request>,
-) -> Result<(IdentityCookie, Empty), Error> {
- if let Some(secret) = identity.secret() {
- let validated_ident = app.tokens().validate(&secret, &now).await?;
- app.tokens().logout(&validated_ident.token).await?;
- }
-
- let identity = identity.clear();
- Ok((identity, Empty))
-}
-
-// This forces the only valid request to be `{}`, and not the infinite
-// variation allowed when there's no body extractor.
-#[derive(Default, serde::Deserialize)]
-pub struct Request {}
-
-#[derive(Debug, thiserror::Error)]
-#[error(transparent)]
-pub struct Error(#[from] pub app::ValidateError);
-
-impl IntoResponse for Error {
- fn into_response(self) -> Response {
- let Self(error) = self;
- match error {
- app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => {
- Unauthorized.into_response()
- }
- app::ValidateError::Name(_) | app::ValidateError::Database(_) => {
- Internal::from(error).into_response()
- }
- }
- }
-}
diff --git a/src/user/handlers/logout/test.rs b/src/user/handlers/logout/test.rs
deleted file mode 100644
index 8ad4853..0000000
--- a/src/user/handlers/logout/test.rs
+++ /dev/null
@@ -1,72 +0,0 @@
-use axum::extract::{Json, State};
-
-use crate::{
- empty::Empty,
- test::{fixtures, verify},
- token::app,
-};
-
-#[tokio::test]
-async fn successful() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
- let now = fixtures::now();
- let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await;
- let identity = fixtures::cookie::logged_in(&app, &creds, &now).await;
- let secret = fixtures::cookie::secret(&identity);
-
- // Call the endpoint
-
- let (response_identity, Empty) = super::handler(
- State(app.clone()),
- fixtures::now(),
- identity.clone(),
- Json::default(),
- )
- .await
- .expect("logged out with a valid token");
-
- // Verify the return value's basic structure
- assert!(response_identity.secret().is_none());
-
- // Verify the semantics
- verify::token::invalid(&app, &secret).await;
-}
-
-#[tokio::test]
-async fn no_identity() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
-
- // Call the endpoint
-
- let identity = fixtures::cookie::not_logged_in();
- let (identity, Empty) = super::handler(State(app), fixtures::now(), identity, Json::default())
- .await
- .expect("logged out with no token succeeds");
-
- // Verify the return value's basic structure
-
- assert!(identity.secret().is_none());
-}
-
-#[tokio::test]
-async fn invalid_token() {
- // Set up the environment
-
- let app = fixtures::scratch_app().await;
-
- // Call the endpoint
-
- let identity = fixtures::cookie::fictitious();
- let super::Error(error) =
- super::handler(State(app), fixtures::now(), identity, Json::default())
- .await
- .expect_err("logged out with an invalid token fails");
-
- // Verify the return value's basic structure
-
- assert!(matches!(error, app::ValidateError::InvalidToken));
-}
diff --git a/src/user/handlers/mod.rs b/src/user/handlers/mod.rs
deleted file mode 100644
index 5cadbb5..0000000
--- a/src/user/handlers/mod.rs
+++ /dev/null
@@ -1,7 +0,0 @@
-mod login;
-mod logout;
-mod password;
-
-pub use login::handler as login;
-pub use logout::handler as logout;
-pub use password::handler as change_password;
diff --git a/src/user/handlers/password/mod.rs b/src/user/handlers/password/mod.rs
deleted file mode 100644
index 5e69c1c..0000000
--- a/src/user/handlers/password/mod.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-use axum::{
- extract::{Json, State},
- http::StatusCode,
- response::{IntoResponse, Response},
-};
-
-use crate::{
- app::App,
- clock::RequestedAt,
- empty::Empty,
- error::Internal,
- password::Password,
- token::{
- app,
- extract::{Identity, IdentityCookie},
- },
-};
-
-#[cfg(test)]
-mod test;
-
-pub async fn handler(
- State(app): State<App>,
- RequestedAt(now): RequestedAt,
- identity: Identity,
- cookie: IdentityCookie,
- Json(request): Json<Request>,
-) -> Result<(IdentityCookie, Empty), Error> {
- let secret = app
- .tokens()
- .change_password(&identity.user, &request.password, &request.to, &now)
- .await
- .map_err(Error)?;
- let cookie = cookie.set(secret);
- Ok((cookie, Empty))
-}
-
-#[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/user/handlers/password/test.rs b/src/user/handlers/password/test.rs
deleted file mode 100644
index 81020a1..0000000
--- a/src/user/handlers/password/test.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-use axum::extract::{Json, State};
-
-use crate::{
- empty::Empty,
- test::{fixtures, verify},
-};
-
-#[tokio::test]
-async fn password_change() {
- // Set up the environment
- let app = fixtures::scratch_app().await;
- let creds = fixtures::user::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::user::propose_password();
- let request = super::Request {
- password: password.clone(),
- to: to.clone(),
- };
- let (new_cookie, Empty) = super::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
- verify::identity::valid_for_user(&app, &new_cookie, &identity.user).await;
-
- // Verify that our original token is no longer valid
- verify::identity::invalid(&app, &cookie).await;
-
- // Verify that our original password is no longer valid
- verify::login::invalid_login(&app, &name, &password).await;
-
- // Verify that our new password is valid
- verify::login::valid_login(&app, &name, &to).await;
-}
diff --git a/src/user/history.rs b/src/user/history.rs
index 72e0aee..f58e9c7 100644
--- a/src/user/history.rs
+++ b/src/user/history.rs
@@ -1,8 +1,8 @@
use super::{
- Id, User,
+ User,
event::{Created, Event},
};
-use crate::event::Instant;
+use crate::event::{Instant, Sequence};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct History {
@@ -12,21 +12,13 @@ pub struct History {
// State interface
impl History {
- pub fn id(&self) -> &Id {
- &self.user.id
- }
-
- // Snapshot of this user as it was when created. (Note to the future: it's okay
- // if this returns a redacted or modified version of the user. If we implement
- // renames by redacting the original name, then this should return the edited
- // user, not the original, even if that's not how it was "as created.")
- pub fn as_created(&self) -> User {
- self.user.clone()
- }
-
- // Snapshot of this user, as of all events recorded in this history.
- pub fn as_snapshot(&self) -> Option<User> {
- self.events().collect()
+ pub fn as_of<S>(&self, sequence: S) -> Option<User>
+ where
+ S: Into<Sequence>,
+ {
+ self.events()
+ .filter(Sequence::up_to(sequence.into()))
+ .collect()
}
}
diff --git a/src/user/id.rs b/src/user/id.rs
index 3ad8d16..ceb310a 100644
--- a/src/user/id.rs
+++ b/src/user/id.rs
@@ -1,7 +1,24 @@
+use crate::login;
+
// Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L`
// prefix, instead.
pub type Id = crate::id::Id<User>;
+// Login IDs and user IDs are typewise-distinct as they identify things in different namespaces, but
+// in practice a login and its associated user _must_ have IDs that encode to the same value. The
+// two ID types are made interconvertible (via `From`) for this purpose.
+impl From<login::Id> for Id {
+ fn from(login: login::Id) -> Self {
+ Self::from(String::from(login))
+ }
+}
+
+impl PartialEq<login::Id> for Id {
+ fn eq(&self, other: &login::Id) -> bool {
+ self.as_str().eq(other.as_str())
+ }
+}
+
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct User;
diff --git a/src/user/mod.rs b/src/user/mod.rs
index 60ec209..95bec2f 100644
--- a/src/user/mod.rs
+++ b/src/user/mod.rs
@@ -2,9 +2,8 @@
pub mod app;
pub mod create;
pub mod event;
-pub mod handlers;
mod history;
-mod id;
+pub mod id;
pub mod repo;
mod snapshot;
mod validate;
diff --git a/src/user/repo.rs b/src/user/repo.rs
index bfb603d..aaf3b73 100644
--- a/src/user/repo.rs
+++ b/src/user/repo.rs
@@ -3,9 +3,10 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
use crate::{
clock::DateTime,
+ db::NotFound,
event::{Instant, Sequence},
+ login::Login,
name::{self, Name},
- password::StoredHash,
user::{History, Id, User},
};
@@ -24,71 +25,58 @@ pub struct Users<'t>(&'t mut SqliteConnection);
impl Users<'_> {
pub async fn create(
&mut self,
- name: &Name,
- password: &StoredHash,
+ login: &Login,
created: &Instant,
) -> Result<History, sqlx::Error> {
- let id = Id::generate();
- let display_name = name.display();
- let canonical_name = name.canonical();
-
- sqlx::query!(
- r#"
- insert into login (id, display_name, canonical_name, password)
- values ($1, $2, $3, $4)
- "#,
- id,
- display_name,
- canonical_name,
- password,
- )
- .execute(&mut *self.0)
- .await?;
-
sqlx::query!(
r#"
insert into user (id, created_sequence, created_at)
values ($1, $2, $3)
"#,
- id,
+ login.id,
created.sequence,
created.at,
)
.execute(&mut *self.0)
.await?;
- let user = History {
- created: *created,
+ Ok(History {
user: User {
- id,
- name: name.clone(),
+ id: login.id.clone().into(),
+ name: login.name.clone(),
},
- };
-
- Ok(user)
+ created: *created,
+ })
}
- pub async fn set_password(
- &mut self,
- login: &History,
- to: &StoredHash,
- ) -> Result<(), sqlx::Error> {
- let login = login.id();
-
- sqlx::query_scalar!(
+ pub async fn by_login(&mut self, login: &Login) -> Result<History, LoadError> {
+ let user = sqlx::query!(
r#"
- update login
- set password = $1
- where id = $2
- returning id as "id: Id"
+ select
+ id as "id: Id",
+ login.display_name as "display_name: String",
+ login.canonical_name as "canonical_name: String",
+ user.created_at as "created_at: DateTime",
+ user.created_sequence as "created_sequence: Sequence"
+ from user
+ join login using (id)
+ where id = $1
"#,
- to,
- login,
+ login.id,
)
+ .map(|row| {
+ Ok::<_, LoadError>(History {
+ user: User {
+ id: row.id,
+ name: Name::new(row.display_name, row.canonical_name)?,
+ },
+ created: Instant::new(row.created_at, row.created_sequence),
+ })
+ })
.fetch_one(&mut *self.0)
- .await?;
+ .await??;
- Ok(())
+ Ok(user)
}
pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> {
@@ -163,3 +151,16 @@ 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),
+ }
+ }
+}