summaryrefslogtreecommitdiff
path: root/src/login/repo
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-03 01:25:20 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-03 02:09:25 -0400
commitb404344a7c4ab5cb6c7d7b445fab796be79b848f (patch)
treec476b125316b9d4aa7bdece7c9bb8e2f65d2961e /src/login/repo
parent92a7518975c6bc4b2f9b9c6c12c458b24e8cfaf5 (diff)
Allow login creation and authentication.
This is a beefy change, as it adds a TON of smaller pieces needed to make this all function: * A database migration. * A ton of new crates for things like password validation, timekeeping, and HTML generation. * A first cut at a module structure for routes, templates, repositories. * A family of ID types, for identifying various kinds of domain thing. * AppError, which _doesn't_ implement Error but can be sent to clients.
Diffstat (limited to 'src/login/repo')
-rw-r--r--src/login/repo/logins.rs167
-rw-r--r--src/login/repo/mod.rs2
-rw-r--r--src/login/repo/tokens.rs47
3 files changed, 216 insertions, 0 deletions
diff --git a/src/login/repo/logins.rs b/src/login/repo/logins.rs
new file mode 100644
index 0000000..c6db86e
--- /dev/null
+++ b/src/login/repo/logins.rs
@@ -0,0 +1,167 @@
+use argon2::Argon2;
+use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
+use rand_core::OsRng;
+use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
+
+use crate::error::BoxedError;
+use crate::id::Id as BaseId;
+
+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);
+
+#[derive(Debug)]
+pub struct Login {
+ pub id: Id,
+ // Field unused (as of this writing), omitted to avoid warnings.
+ // Feel free to add it:
+ //
+ // pub name: String,
+
+ // However, 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> {
+ /// Create a new login, if the name is not already taken. Returns a [Login]
+ /// if a new login has actually been created, or `None` if an existing login
+ /// was found.
+ pub async fn create(
+ &mut self,
+ name: &str,
+ password: &str,
+ ) -> Result<Option<Login>, BoxedError> {
+ let id = Id::generate();
+ let password_hash = StoredHash::new(password)?;
+
+ let insert_res = sqlx::query_as!(
+ Login,
+ r#"
+ insert or fail
+ into login (id, name, password_hash)
+ values ($1, $2, $3)
+ returning id as "id: Id"
+ "#,
+ id,
+ name,
+ password_hash,
+ )
+ .fetch_one(&mut *self.0)
+ .await;
+
+ let result = match insert_res {
+ Ok(id) => Ok(Some(id)),
+ Err(err) => {
+ if let Some(true) = err
+ .as_database_error()
+ .map(|db_err| db_err.is_unique_violation())
+ {
+ // Login with the same username (or, very rarely, same ID) already
+ // exists.
+ Ok(None)
+ } else {
+ Err(err)
+ }
+ }
+ }?;
+ Ok(result)
+ }
+
+ /// Authenticates `name` and `password` against an existing [Login]. Returns
+ /// that [Login] if one was found and the password was correct, or `None` if
+ /// either condition does not hold.
+ pub async fn authenticate(
+ &mut self,
+ name: &str,
+ password: &str,
+ ) -> Result<Option<Login>, BoxedError> {
+ let found = self.for_name(name).await?;
+
+ let login = if let Some((login, stored_hash)) = found {
+ if stored_hash.verify(password)? {
+ // User found and password validation succeeded.
+ Some(login)
+ } else {
+ // Password validation failed.
+ None
+ }
+ } else {
+ // User not found.
+ None
+ };
+
+ Ok(login)
+ }
+
+ async fn for_name(&mut self, name: &str) -> Result<Option<(Login, StoredHash)>, BoxedError> {
+ let found = sqlx::query!(
+ r#"
+ select
+ id as "id: Id",
+ password_hash as "password_hash: StoredHash"
+ from login
+ where name = $1
+ "#,
+ name,
+ )
+ .map(|rec| (Login { id: rec.id }, rec.password_hash))
+ .fetch_optional(&mut *self.0)
+ .await?;
+
+ Ok(found)
+ }
+}
+
+/// Stable identifier for a [Login]. Prefixed with `L`.
+#[derive(Debug, sqlx::Type)]
+#[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")
+ }
+}
+
+#[derive(Debug, sqlx::Type)]
+#[sqlx(transparent)]
+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/repo/mod.rs b/src/login/repo/mod.rs
new file mode 100644
index 0000000..07da569
--- /dev/null
+++ b/src/login/repo/mod.rs
@@ -0,0 +1,2 @@
+pub mod logins;
+pub mod tokens;
diff --git a/src/login/repo/tokens.rs b/src/login/repo/tokens.rs
new file mode 100644
index 0000000..080e35a
--- /dev/null
+++ b/src/login/repo/tokens.rs
@@ -0,0 +1,47 @@
+use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
+use uuid::Uuid;
+
+use super::logins::Id as LoginId;
+use crate::error::BoxedError;
+
+type DateTime = chrono::DateTime<chrono::Utc>;
+
+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.
+ pub async fn issue(
+ &mut self,
+ login: &LoginId,
+ issued_at: DateTime,
+ ) -> Result<String, BoxedError> {
+ let secret = Uuid::new_v4().to_string();
+
+ let secret = sqlx::query_scalar!(
+ r#"
+ insert
+ into token (secret, login, issued_at)
+ values ($1, $2, $3)
+ returning secret as "secret!"
+ "#,
+ secret,
+ login,
+ issued_at,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ Ok(secret)
+ }
+}