From b404344a7c4ab5cb6c7d7b445fab796be79b848f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 3 Sep 2024 01:25:20 -0400 Subject: 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. --- src/login/mod.rs | 4 ++ src/login/repo/logins.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++ src/login/repo/mod.rs | 2 + src/login/repo/tokens.rs | 47 +++++++++++++ src/login/routes.rs | 78 ++++++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 src/login/mod.rs create mode 100644 src/login/repo/logins.rs create mode 100644 src/login/repo/mod.rs create mode 100644 src/login/repo/tokens.rs create mode 100644 src/login/routes.rs (limited to 'src/login') diff --git a/src/login/mod.rs b/src/login/mod.rs new file mode 100644 index 0000000..8769407 --- /dev/null +++ b/src/login/mod.rs @@ -0,0 +1,4 @@ +pub use self::routes::router; + +mod repo; +mod routes; 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, 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, 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, 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 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 { + 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 { + 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; + +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 { + 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) + } +} diff --git a/src/login/routes.rs b/src/login/routes.rs new file mode 100644 index 0000000..c9def2a --- /dev/null +++ b/src/login/routes.rs @@ -0,0 +1,78 @@ +use axum::{ + extract::{Form, State}, + http::StatusCode, + response::IntoResponse, + routing::post, + Router, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use chrono::Utc; +use sqlx::sqlite::SqlitePool; + +use crate::error::InternalError; + +use super::repo::{logins::Provider as _, tokens::Provider as _}; + +pub fn router() -> Router { + Router::new().route("/login", post(on_login)) +} + +#[derive(serde::Deserialize)] +struct Login { + name: String, + password: String, +} + +async fn on_login( + State(db): State, + cookies: CookieJar, + Form(form): Form, +) -> Result { + let now = Utc::now(); + let mut tx = db.begin().await?; + + // Spelling the following in the more conventional form, + // if let Some(…) = create().await? {} + // else if let Some(…) = validate().await? {} + // else {} + // pushes the specifics of whether the returned error types are Send or not + // (they aren't) into the type of this function's generated Futures, which + // in turn makes this function unusable as an Axum handler. + let login = tx.logins().create(&form.name, &form.password).await?; + let login = if login.is_some() { + login + } else { + tx.logins().authenticate(&form.name, &form.password).await? + }; + + // If `login` is Some, then we have an identity and can issue an identity + // token. If `login` is None, then neither creating a new login nor authenticating + // an existing one succeeded, and we must reject the attempt. + // + // These properties will be transferred to `token`, as well. + let token = if let Some(login) = login { + Some(tx.tokens().issue(&login.id, now).await?) + } else { + None + }; + + tx.commit().await?; + + let resp = if let Some(token) = token { + let cookie = Cookie::build(("identity", token)) + .http_only(true) + .permanent() + .build(); + let cookies = cookies.add(cookie); + + (StatusCode::OK, cookies, "logged in") + } else { + ( + StatusCode::UNAUTHORIZED, + cookies, + "invalid name or password", + ) + }; + + Ok(resp) +} -- cgit v1.2.3