summaryrefslogtreecommitdiff
path: root/src/login
diff options
context:
space:
mode:
Diffstat (limited to 'src/login')
-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
5 files changed, 298 insertions, 0 deletions
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)
+}