summaryrefslogtreecommitdiff
path: root/src/login
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-15 23:50:41 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-16 11:03:22 -0400
commit491cb3eb34d20140aed80dbb9edc39c4db5335d2 (patch)
treee1e2e009f064dc6dfc8c98d2bf97d8d1f7b45615 /src/login
parent99b33023332393e46f5a661901b980b78e6fb133 (diff)
Consolidate most repository types into a repo module.
Having them contained in the individual endpoint groups conveyed an unintended sense that their intended scope was _only_ that endpoint group. It also made most repo-related import paths _quite_ long. This splits up the repos as follows: * "General applicability" repos - those that are only loosely connected to a single task, and are likely to be shared between tasks - go in crate::repo. * Specialized repos - those tightly connected to a specific task - go in the module for that task, under crate::PATH::repo. In both cases, each repo goes in its own submodule, to make it easier to use the module name as a namespace. Which category a repo goes in is a judgment call. `crate::channel::repo::broadcast` (formerly `channel::repo::messages`) is used outside of `crate::channel`, for example, but its main purpose is to support channel message broadcasts. It could arguably live under `crate::event::repo::channel`, but the resulting namespace is less legible to me.
Diffstat (limited to 'src/login')
-rw-r--r--src/login/app.rs46
-rw-r--r--src/login/extract.rs (renamed from src/login/extract/identity_token.rs)2
-rw-r--r--src/login/extract/login.rs59
-rw-r--r--src/login/extract/mod.rs4
-rw-r--r--src/login/mod.rs4
-rw-r--r--src/login/repo/auth.rs53
-rw-r--r--src/login/repo/logins.rs136
-rw-r--r--src/login/repo/mod.rs3
-rw-r--r--src/login/repo/tokens.rs125
9 files changed, 66 insertions, 366 deletions
diff --git a/src/login/app.rs b/src/login/app.rs
index cd65f35..c82da1a 100644
--- a/src/login/app.rs
+++ b/src/login/app.rs
@@ -1,13 +1,15 @@
-use argon2::Argon2;
-use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
-use rand_core::OsRng;
use sqlx::sqlite::SqlitePool;
-use super::repo::{
- logins::{Login, Provider as _},
- tokens::Provider as _,
+use super::repo::auth::Provider as _;
+use crate::{
+ clock::DateTime,
+ error::BoxedError,
+ password::StoredHash,
+ repo::{
+ login::{Login, Provider as _},
+ token::Provider as _,
+ },
};
-use crate::{clock::DateTime, error::BoxedError};
pub struct Logins<'a> {
db: &'a SqlitePool,
@@ -26,7 +28,7 @@ impl<'a> Logins<'a> {
) -> Result<Option<String>, BoxedError> {
let mut tx = self.db.begin().await?;
- let login = if let Some((login, stored_hash)) = tx.logins().for_login(name).await? {
+ let login = if let Some((login, stored_hash)) = tx.auth().for_name(name).await? {
if stored_hash.verify(password)? {
// Password verified; use the login.
Some(login)
@@ -75,31 +77,3 @@ impl<'a> Logins<'a> {
Ok(())
}
}
-
-#[derive(Debug, sqlx::Type)]
-#[sqlx(transparent)]
-pub struct StoredHash(String);
-
-impl StoredHash {
- fn new(password: &str) -> Result<Self, password_hash::Error> {
- let salt = SaltString::generate(&mut OsRng);
- let argon2 = Argon2::default();
- let hash = argon2
- .hash_password(password.as_bytes(), &salt)?
- .to_string();
- Ok(Self(hash))
- }
-
- fn verify(&self, password: &str) -> Result<bool, password_hash::Error> {
- let hash = PasswordHash::new(&self.0)?;
-
- match Argon2::default().verify_password(password.as_bytes(), &hash) {
- // Successful authentication, not an error
- Ok(()) => Ok(true),
- // Unsuccessful authentication, also not an error
- Err(password_hash::errors::Error::Password) => Ok(false),
- // Password validation failed for some other reason, treat as an error
- Err(err) => Err(err),
- }
- }
-}
diff --git a/src/login/extract/identity_token.rs b/src/login/extract.rs
index c813324..735bc22 100644
--- a/src/login/extract/identity_token.rs
+++ b/src/login/extract.rs
@@ -1,5 +1,3 @@
-use std::convert::Infallible;
-
use axum::{
extract::FromRequestParts,
http::request::Parts,
diff --git a/src/login/extract/login.rs b/src/login/extract/login.rs
deleted file mode 100644
index 8b5bb41..0000000
--- a/src/login/extract/login.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-use axum::{
- extract::{FromRequestParts, State},
- http::{request::Parts, StatusCode},
- response::{IntoResponse, Response},
-};
-
-use crate::{
- app::App,
- clock::RequestedAt,
- error::InternalError,
- login::{extract::IdentityToken, repo::logins::Login},
-};
-
-#[async_trait::async_trait]
-impl FromRequestParts<App> for Login {
- type Rejection = LoginError<InternalError>;
-
- async fn from_request_parts(parts: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
- // After Rust 1.82 (and #[feature(min_exhaustive_patterns)] lands on
- // stable), the following can be replaced:
- //
- // let Ok(identity_token) = IdentityToken::from_request_parts(parts, state).await;
- let identity_token = IdentityToken::from_request_parts(parts, state).await?;
- let RequestedAt(used_at) = RequestedAt::from_request_parts(parts, state).await?;
-
- let secret = identity_token.secret().ok_or(LoginError::Unauthorized)?;
-
- let app = State::<App>::from_request_parts(parts, state).await?;
- let login = app.logins().validate(secret, used_at).await?;
-
- login.ok_or(LoginError::Unauthorized)
- }
-}
-
-pub enum LoginError<E> {
- Failure(E),
- Unauthorized,
-}
-
-impl<E> IntoResponse for LoginError<E>
-where
- E: IntoResponse,
-{
- fn into_response(self) -> Response {
- match self {
- Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized").into_response(),
- Self::Failure(e) => e.into_response(),
- }
- }
-}
-
-impl<E> From<E> for LoginError<InternalError>
-where
- E: Into<InternalError>,
-{
- fn from(err: E) -> Self {
- Self::Failure(err.into())
- }
-}
diff --git a/src/login/extract/mod.rs b/src/login/extract/mod.rs
deleted file mode 100644
index ba943a6..0000000
--- a/src/login/extract/mod.rs
+++ /dev/null
@@ -1,4 +0,0 @@
-mod identity_token;
-mod login;
-
-pub use self::identity_token::IdentityToken;
diff --git a/src/login/mod.rs b/src/login/mod.rs
index 5070301..191cce0 100644
--- a/src/login/mod.rs
+++ b/src/login/mod.rs
@@ -1,6 +1,6 @@
pub use self::routes::router;
pub mod app;
-mod extract;
-pub mod repo;
+pub mod extract;
+mod repo;
mod routes;
diff --git a/src/login/repo/auth.rs b/src/login/repo/auth.rs
new file mode 100644
index 0000000..78b44f0
--- /dev/null
+++ b/src/login/repo/auth.rs
@@ -0,0 +1,53 @@
+use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
+
+use crate::{
+ password::StoredHash,
+ repo::login::{self, Login},
+};
+
+pub trait Provider {
+ fn auth(&mut self) -> Auth;
+}
+
+impl<'c> Provider for Transaction<'c, Sqlite> {
+ fn auth(&mut self) -> Auth {
+ Auth(self)
+ }
+}
+
+pub struct Auth<'t>(&'t mut SqliteConnection);
+
+impl<'t> Auth<'t> {
+ /// Retrieves a login by name, plus its stored password hash for
+ /// verification. If there's no login with the requested name, this will
+ /// return [None].
+ pub async fn for_name(
+ &mut self,
+ name: &str,
+ ) -> Result<Option<(Login, StoredHash)>, sqlx::Error> {
+ let found = sqlx::query!(
+ r#"
+ select
+ id as "id: login::Id",
+ name,
+ password_hash as "password_hash: StoredHash"
+ from login
+ where name = $1
+ "#,
+ name,
+ )
+ .map(|rec| {
+ (
+ Login {
+ id: rec.id,
+ name: rec.name,
+ },
+ rec.password_hash,
+ )
+ })
+ .fetch_optional(&mut *self.0)
+ .await?;
+
+ Ok(found)
+ }
+}
diff --git a/src/login/repo/logins.rs b/src/login/repo/logins.rs
deleted file mode 100644
index 11ae50f..0000000
--- a/src/login/repo/logins.rs
+++ /dev/null
@@ -1,136 +0,0 @@
-use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
-
-use crate::id::Id as BaseId;
-use crate::login::app::StoredHash;
-
-pub trait Provider {
- fn logins(&mut self) -> Logins;
-}
-
-impl<'c> Provider for Transaction<'c, Sqlite> {
- fn logins(&mut self) -> Logins {
- Logins(self)
- }
-}
-
-pub struct Logins<'t>(&'t mut SqliteConnection);
-
-// This also implements FromRequestParts (see `src/login/extract/login.rs`). As
-// a result, it can be used as an extractor.
-#[derive(Clone, Debug, serde::Serialize)]
-pub struct Login {
- pub id: Id,
- pub name: String,
- // The omission of the hashed password is deliberate, to minimize the
- // chance that it ends up tangled up in debug output or in some other chunk
- // of logic elsewhere.
-}
-
-impl<'c> Logins<'c> {
- pub async fn create(
- &mut self,
- name: &str,
- password_hash: &StoredHash,
- ) -> Result<Login, sqlx::Error> {
- let id = Id::generate();
-
- let login = sqlx::query_as!(
- Login,
- r#"
- insert or fail
- into login (id, name, password_hash)
- values ($1, $2, $3)
- returning
- id as "id: Id",
- name
- "#,
- id,
- name,
- password_hash,
- )
- .fetch_one(&mut *self.0)
- .await?;
-
- Ok(login)
- }
-
- pub async fn by_id(&mut self, id: &Id) -> Result<Login, sqlx::Error> {
- let login = sqlx::query_as!(
- Login,
- r#"
- select
- id as "id: Id",
- name
- from login
- where id = $1
- "#,
- id,
- )
- .fetch_one(&mut *self.0)
- .await?;
-
- Ok(login)
- }
-
- /// Retrieves a login by name, plus its stored password hash for
- /// verification. If there's no login with the requested name, this will
- /// return [None].
- pub async fn for_login(
- &mut self,
- name: &str,
- ) -> Result<Option<(Login, StoredHash)>, sqlx::Error> {
- let found = sqlx::query!(
- r#"
- select
- id as "id: Id",
- name,
- password_hash as "password_hash: StoredHash"
- from login
- where name = $1
- "#,
- name,
- )
- .map(|rec| {
- (
- Login {
- id: rec.id,
- name: rec.name,
- },
- rec.password_hash,
- )
- })
- .fetch_optional(&mut *self.0)
- .await?;
-
- Ok(found)
- }
-}
-
-impl<'t> From<&'t mut SqliteConnection> for Logins<'t> {
- fn from(tx: &'t mut SqliteConnection) -> Self {
- Self(tx)
- }
-}
-
-/// Stable identifier for a [Login]. Prefixed with `L`.
-#[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, 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("L")
- }
-}
-
-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/login/repo/mod.rs b/src/login/repo/mod.rs
index 07da569..0e4a05d 100644
--- a/src/login/repo/mod.rs
+++ b/src/login/repo/mod.rs
@@ -1,2 +1 @@
-pub mod logins;
-pub mod tokens;
+pub mod auth;
diff --git a/src/login/repo/tokens.rs b/src/login/repo/tokens.rs
deleted file mode 100644
index ec95f6a..0000000
--- a/src/login/repo/tokens.rs
+++ /dev/null
@@ -1,125 +0,0 @@
-use chrono::TimeDelta;
-use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
-use uuid::Uuid;
-
-use super::logins::{Id as LoginId, Login};
-use crate::clock::DateTime;
-
-pub trait Provider {
- fn tokens(&mut self) -> Tokens;
-}
-
-impl<'c> Provider for Transaction<'c, Sqlite> {
- fn tokens(&mut self) -> Tokens {
- Tokens(self)
- }
-}
-
-pub struct Tokens<'t>(&'t mut SqliteConnection);
-
-impl<'c> Tokens<'c> {
- /// Issue a new token for an existing login. The issued_at timestamp will
- /// be used to control expiry, until the token is actually used.
- pub async fn issue(
- &mut self,
- login: &LoginId,
- issued_at: DateTime,
- ) -> Result<String, sqlx::Error> {
- let secret = Uuid::new_v4().to_string();
-
- let secret = sqlx::query_scalar!(
- r#"
- insert
- into token (secret, login, issued_at, last_used_at)
- values ($1, $2, $3, $3)
- returning secret as "secret!"
- "#,
- secret,
- login,
- issued_at,
- )
- .fetch_one(&mut *self.0)
- .await?;
-
- Ok(secret)
- }
-
- /// Revoke a token by its secret.
- pub async fn revoke(&mut self, secret: &str) -> Result<(), sqlx::Error> {
- sqlx::query!(
- r#"
- delete
- from token
- where secret = $1
- returning 1 as "found: u32"
- "#,
- secret,
- )
- .fetch_one(&mut *self.0)
- .await?;
-
- Ok(())
- }
-
- /// Expire and delete all tokens that haven't been used within the expiry
- /// interval (right now, 7 days) prior to `expire_at`. Tokens that are in
- /// use within that period will be retained.
- pub async fn expire(&mut self, expire_at: DateTime) -> Result<(), sqlx::Error> {
- // Somewhat arbitrarily, expire after 7 days.
- let expired_issue_at = expire_at - TimeDelta::days(7);
- sqlx::query!(
- r#"
- delete
- from token
- where last_used_at < $1
- "#,
- expired_issue_at,
- )
- .execute(&mut *self.0)
- .await?;
-
- Ok(())
- }
-
- /// Validate a token by its secret, retrieving the associated Login record.
- /// Will return [None] if the token is not valid. The token's last-used
- /// timestamp will be set to `used_at`.
- pub async fn validate(
- &mut self,
- secret: &str,
- used_at: DateTime,
- ) -> Result<Option<Login>, sqlx::Error> {
- // 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.
- sqlx::query!(
- r#"
- update token
- set last_used_at = $1
- where secret = $2
- "#,
- used_at,
- secret,
- )
- .execute(&mut *self.0)
- .await?;
-
- let login = sqlx::query_as!(
- Login,
- r#"
- select
- login.id as "id: LoginId",
- name
- from login
- join token on login.id = token.login
- where token.secret = $1
- "#,
- secret,
- )
- .fetch_optional(&mut *self.0)
- .await?;
-
- Ok(login)
- }
-}