summaryrefslogtreecommitdiff
path: root/src/login/app.rs
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/login/app.rs
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/login/app.rs')
-rw-r--r--src/login/app.rs114
1 files changed, 114 insertions, 0 deletions
diff --git a/src/login/app.rs b/src/login/app.rs
new file mode 100644
index 0000000..77d4ac3
--- /dev/null
+++ b/src/login/app.rs
@@ -0,0 +1,114 @@
+use sqlx::sqlite::SqlitePool;
+
+use crate::{
+ clock::DateTime,
+ db::NotFound as _,
+ login::{self, Login, repo::Provider as _},
+ name::{self, Name},
+ password::Password,
+ token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _},
+};
+
+pub struct Logins<'a> {
+ db: &'a SqlitePool,
+ token_events: &'a Broadcaster,
+}
+
+impl<'a> Logins<'a> {
+ pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self {
+ Self { db, token_events }
+ }
+
+ pub async fn with_password(
+ &self,
+ name: &Name,
+ candidate: &Password,
+ login_at: &DateTime,
+ ) -> Result<Secret, LoginError> {
+ let mut tx = self.db.begin().await?;
+ let (login, password) = tx
+ .logins()
+ .by_name(name)
+ .await
+ .not_found(|| 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 password.verify(candidate)? {
+ let mut tx = self.db.begin().await?;
+ let (token, secret) = Token::generate(&login, login_at);
+ tx.tokens().create(&token, &secret).await?;
+ tx.commit().await?;
+ Ok(secret)
+ } else {
+ Err(LoginError::Rejected)
+ }
+ }
+
+ pub async fn change_password(
+ &self,
+ login: &Login,
+ from: &Password,
+ to: &Password,
+ changed_at: &DateTime,
+ ) -> Result<Secret, LoginError> {
+ let mut tx = self.db.begin().await?;
+ let (login, password) = tx
+ .logins()
+ .by_id(&login.id)
+ .await
+ .not_found(|| 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 password.verify(from)? {
+ let to_hash = to.hash()?;
+ let (token, secret) = Token::generate(&login, changed_at);
+
+ let mut tx = self.db.begin().await?;
+ tx.logins().set_password(&login, &to_hash).await?;
+
+ let revoked = tx.tokens().revoke_all(&login).await?;
+ tx.tokens().create(&token, &secret).await?;
+ tx.commit().await?;
+
+ for event in revoked.into_iter().map(TokenEvent::Revoked) {
+ self.token_events.broadcast(event);
+ }
+
+ Ok(secret)
+ } else {
+ Err(LoginError::Rejected)
+ }
+ }
+}
+
+#[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<login::repo::LoadError> for LoginError {
+ fn from(error: login::repo::LoadError) -> Self {
+ use login::repo::LoadError;
+ match error {
+ LoadError::Database(error) => error.into(),
+ LoadError::Name(error) => error.into(),
+ }
+ }
+}