summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/cli.rs16
-rw-r--r--src/error.rs37
-rw-r--r--src/id.rs49
-rw-r--r--src/index.rs37
-rw-r--r--src/lib.rs4
-rw-r--r--src/login/mod.rs4
-rw-r--r--src/login/repo/logins.rs167
-rw-r--r--src/login/repo/mod.rs2
-rw-r--r--src/login/repo/tokens.rs47
-rw-r--r--src/login/routes.rs78
10 files changed, 434 insertions, 7 deletions
diff --git a/src/cli.rs b/src/cli.rs
index 0880020..191e331 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,12 +1,14 @@
use std::io;
use std::str::FromStr;
-use axum::{routing::get, Router};
+use axum::Router;
use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tokio::net;
-pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+use crate::{error::BoxedError, index, login};
+
+pub type Result<T> = std::result::Result<T, BoxedError>;
#[derive(Parser)]
pub struct Args {
@@ -26,7 +28,7 @@ impl Args {
sqlx::migrate!().run(&pool).await?;
- let app = Router::new().route("/", get(hello)).with_state(pool);
+ let app = routers().with_state(pool);
let listener = self.listener().await?;
let started_msg = started_msg(&listener)?;
@@ -58,11 +60,11 @@ impl Args {
}
}
+fn routers() -> Router<SqlitePool> {
+ index::router().merge(login::router())
+}
+
fn started_msg(listener: &net::TcpListener) -> io::Result<String> {
let local_addr = listener.local_addr()?;
Ok(format!("listening on http://{local_addr}/"))
}
-
-async fn hello() -> &'static str {
- "Hello, world"
-}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..2d512e6
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,37 @@
+use std::error;
+
+use axum::{
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+
+// I'm making an effort to avoid `anyhow` here, as that crate is _enormously_
+// complex (though very usable). We don't need to be overly careful about
+// allocations on errors in this app, so this is fine for most "general
+// failure" cases.
+//
+// If that changes, my hope is to use `thiserror` or something with a similar
+// strategy, before resorting to `anyhow`.
+pub type BoxedError = Box<dyn error::Error + Send + Sync>;
+
+// Returns a 500 Internal Server Error to the client. Meant to be used via the
+// `?` operator; _does not_ return the originating error to the client.
+#[derive(Debug)]
+pub struct InternalError;
+
+impl<E> From<E> for InternalError
+where
+ E: Into<BoxedError>,
+{
+ fn from(_: E) -> InternalError {
+ // At some point it may be useful for this to record the originating
+ // error so that it can be logged… -oj
+ InternalError
+ }
+}
+
+impl IntoResponse for InternalError {
+ fn into_response(self) -> Response {
+ (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
+ }
+}
diff --git a/src/id.rs b/src/id.rs
new file mode 100644
index 0000000..f630107
--- /dev/null
+++ b/src/id.rs
@@ -0,0 +1,49 @@
+use rand::{seq::SliceRandom, thread_rng};
+
+// Make IDs that:
+//
+// * Do not require escaping in URLs
+// * Do not require escaping in hostnames
+// * Are unique up to case conversion
+// * Are relatively unlikely to contain cursewords
+// * Are relatively unlikely to contain visually similar characters in most typefaces
+// * Are not sequential
+//
+// This leaves 23 ASCII characters, or about 4.52 bits of entropy per character
+// if generated with uniform probability.
+pub const ALPHABET: [char; 23] = [
+ '1', '2', '3', '4', '6', '7', '8', '9', 'b', 'c', 'd', 'f', 'h', 'j', 'k', 'n', 'p', 'r', 's',
+ 't', 'w', 'x', 'y',
+];
+
+// Pick enough characters per ID to make accidental collisions "acceptably" unlikely
+// without also making them _too_ unwieldy. This gives a fraction under 68 bits per ID.
+pub const ID_SIZE: usize = 15;
+
+// Intended to be wrapped in a newtype that provides both type-based separation
+// from other identifier types, and a unique prefix to allow the intended type
+// of an ID to be determined by eyeball when debugging.
+//
+// By convention, the prefix should be UPPERCASE - note that the alphabet for this
+// is entirely lowercase.
+#[derive(Debug, Hash, PartialEq, Eq, sqlx::Type)]
+#[sqlx(transparent)]
+pub struct Id(String);
+
+impl Id {
+ pub fn generate<T>(prefix: &str) -> T
+ where
+ T: From<Self>,
+ {
+ let mut rng = thread_rng();
+ let id = prefix
+ .chars()
+ .chain(
+ (0..ID_SIZE)
+ .flat_map(|_| ALPHABET.choose(&mut rng)) /* usize -> &char */
+ .cloned(), /* &char -> char */
+ )
+ .collect::<String>();
+ T::from(Self(id))
+ }
+}
diff --git a/src/index.rs b/src/index.rs
new file mode 100644
index 0000000..6411ff4
--- /dev/null
+++ b/src/index.rs
@@ -0,0 +1,37 @@
+use axum::{response::IntoResponse, routing::get, Router};
+
+pub fn router<S>() -> Router<S>
+where
+ S: Send + Sync + Clone + 'static,
+{
+ Router::new().route("/", get(index))
+}
+
+async fn index() -> impl IntoResponse {
+ templates::index()
+}
+
+mod templates {
+ use maud::{html, Markup, DOCTYPE};
+ pub fn index() -> Markup {
+ html! {
+ (DOCTYPE)
+ head {
+ title { "hi" }
+ }
+ body {
+ form action="/login" method="post" {
+ label {
+ "name"
+ input name="name" type="text" {}
+ }
+ label {
+ "password"
+ input name="password" type="password" {}
+ }
+ button { "hi" }
+ }
+ }
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 4f77372..c91ca43 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,5 @@
pub mod cli;
+mod error;
+mod id;
+mod index;
+mod 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<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)
+ }
+}
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<SqlitePool> {
+ Router::new().route("/login", post(on_login))
+}
+
+#[derive(serde::Deserialize)]
+struct Login {
+ name: String,
+ password: String,
+}
+
+async fn on_login(
+ State(db): State<SqlitePool>,
+ cookies: CookieJar,
+ Form(form): Form<Login>,
+) -> Result<impl IntoResponse, InternalError> {
+ 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)
+}