diff --git a/Cargo.toml b/Cargo.toml index 066a6fd..a8299b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,12 @@ edition = "2021" [dependencies] tokio = { version = "1", features = ["rt-multi-thread"]} -axum = "0.7" +axum = { version = "0.7", default_features = false, features = ["http1", "form", "tokio"] } askama = "0.12" askama_axum = "0.4" tower-http = { version = "0.5", features = ["fs"] } deadpool-postgres = { version = "0.12", features = ["rt_tokio_1"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } -thiserror = "1.0" \ No newline at end of file +thiserror = "1.0" +argon2 = { version = "0.5", features = ["password-hash", "std"] } +serde = { version = "1.0", features = ["derive"] } \ No newline at end of file diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..f86d238 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,69 @@ +use { + crate::{ + authentication::{authenticate_user, Password}, + db::Database, + error::Error, + }, + askama::Template, + askama_axum::{IntoResponse, Response}, + axum::{ + extract::State, + routing::{get, post}, + Form, Router, + }, + serde::Deserialize, +}; + +pub fn routes() -> Router { + Router::new() + .route("/", get(root)) + .route("/create_initial_admin_user", post(create_first_admin_user)) +} + +#[derive(Template)] +#[template(path = "admin/create_first_user.html")] +struct CreateFirstUserTemplate {} + +#[derive(Template)] +#[template(path = "admin/first_login.html")] +struct FirstLoginTemplate {} + +#[derive(Template)] +#[template(path = "admin/index.html")] +struct IndexTemplate {} + +async fn root(State(db): State) -> Result { + Ok(if !db.has_admin_users().await? { + CreateFirstUserTemplate {}.into_response() + } else { + IndexTemplate {}.into_response() + }) +} + +#[derive(Deserialize)] +struct CreateFirstUserParameters { + real_name: String, + email: String, + password: String, +} + +async fn create_first_admin_user( + State(db): State, + Form(params): Form, +) -> Result { + let user = db + .create_first_admin_user( + ¶ms.real_name, + ¶ms.email, + &Password::new(¶ms.password)?.into(), + ) + .await?; + if let Some(_user) = authenticate_user(&db, user, ¶ms.password).await? { + // Store cookie and display configuration page + todo!(); + } else { + // Report failure + todo!(); + } + Ok(FirstLoginTemplate {}) +} diff --git a/src/authentication.rs b/src/authentication.rs new file mode 100644 index 0000000..326c628 --- /dev/null +++ b/src/authentication.rs @@ -0,0 +1,95 @@ +use { + crate::{db, db::Database}, + argon2::{ + password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, + }, + Argon2, + }, + std::ops::Deref, +}; + +#[derive(thiserror::Error, Debug)] +pub enum AuthenticationError { + #[error("Could not get password hash from database: {}", .0.to_string())] + DatabaseError(#[from] db::Error), + + #[error("{}", .0.to_string())] + HashError(#[from] argon2::password_hash::Error), +} + +#[derive(Debug)] +pub struct AuthenticatedUser { + user: db::User, +} + +impl Deref for AuthenticatedUser { + type Target = db::User; + + fn deref(&self) -> &db::User { + &self.user + } +} + +#[derive(Debug)] +pub struct AuthenticatedAdminUser { + user: db::User, +} + +impl Deref for AuthenticatedAdminUser { + type Target = db::User; + + fn deref(&self) -> &db::User { + &self.user + } +} + +pub struct Password { + hash: String, +} + +impl Password { + pub fn new(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + Ok(Password { + hash: argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(), + }) + } + + pub fn check(&self, password: &str) -> Result { + let hash = PasswordHash::new(&self.hash)?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok()) + } +} + +impl From for Password { + fn from(password: db::PasswordHash) -> Self { + Password { + hash: password.to_string(), + } + } +} + +impl From for db::PasswordHash { + fn from(password: Password) -> Self { + db::PasswordHash(password.hash) + } +} + +pub async fn authenticate_user( + db: &Database, + user: db::User, + supplied_password: &str, +) -> Result, AuthenticationError> { + let password: Password = db.get_password_for_user(&user).await?.into(); + Ok(if password.check(supplied_password)? { + Some(AuthenticatedUser { user }) + } else { + None + }) +} diff --git a/src/db/migrations.rs b/src/db/migrations.rs index d4a86b6..1b2a16b 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -42,18 +42,39 @@ struct Migration { /// New versions should normally only ever add to the end of this /// list, never change or add things in the middle or at the /// beginning. -static MIGRATIONS: &[Migration] = &[Migration { - version: 0, - up: r#" - CREATE TABLE users ( - id SERIAL PRIMARY KEY, - real_name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - -- 43 characters is enought to base64-encode 32 bits - password_salt CHARACTER(43) NOT NULL, - password_hash CHARACTER(43) NOT NULL);"#, - down: r#"DROP TABLE users;"#, -}]; +static MIGRATIONS: &[Migration] = &[ + Migration { + version: 0, + up: r#" + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + real_name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + -- 43 characters is enought to base64-encode 32 bits + password_salt CHARACTER(43) NOT NULL, + password_hash CHARACTER(43) NOT NULL);"#, + down: r#"DROP TABLE users;"#, + }, + Migration { + version: 1, + up: r#" + CREATE TABLE admin_users ( + id INTEGER PRIMARY KEY REFERENCES users);"#, + down: r#" + DROP TABLE admin_users;"#, + }, + Migration { + // The original password_salt and password_hash fields were + // never used, and when I implemented passwords I ended up + // storing both in a single row. + version: 2, + up: r#" + ALTER TABLE users DROP COLUMN IF EXISTS password_salt; + ALTER TABLE users DROP COLUMN IF EXISTS password_hash; + ALTER TABLE users ADD COLUMN password TEXT NOT NULL;"#, + down: "ALTER TABLE users DROP COLUMN password;" + } +]; /// The current schema version. Normally this will be the /// [version](Migration::version) of the list item in [MIGRATIONS]. @@ -63,7 +84,7 @@ static MIGRATIONS: &[Migration] = &[Migration { /// it is not the current database schema, then running /// [migrate_to_current_version()] will migrate the database to this /// version. -static CURRENT_VERSION: i32 = 0; +static CURRENT_VERSION: i32 = 2; /// If the database is not already using the most recent schema, apply /// one migration to bring it to the next newest version. @@ -72,12 +93,8 @@ static CURRENT_VERSION: i32 = 0; /// then running this function will apply [Migration] 5 to bring the /// database up to schema version 5. async fn migrate_up(db: &Database) -> Result { - let current_version = dbg!(get_db_version(db).await?); - if let Some(migration) = if current_version < 1 { - MIGRATIONS.first() - } else { - MIGRATIONS.iter().find(|m| m.version == current_version) - } { + let current_version = get_db_version(db).await?; + if let Some(migration) = MIGRATIONS.iter().find(|m| m.version > current_version) { let client = db.connection_pool.get().await?; client .execute(&format!("DO $$BEGIN\n{}\nEND$$;", migration.up), &[]) @@ -88,7 +105,7 @@ async fn migrate_up(db: &Database) -> Result { &[&migration.version], ) .await?; - Ok(dbg!(migration.version)) + Ok(migration.version) } else { eprintln!("ERROR: Attempted to migrate up past last migration."); Ok(current_version) @@ -151,7 +168,7 @@ pub async fn get_db_version(db: &Database) -> Result { .execute( r#" DO $$BEGIN - IF NOT EXISTS + IF NOT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' @@ -192,6 +209,7 @@ mod tests { .execute( r#" DO $$BEGIN + DROP TABlE IF EXISTS admin_users; DROP TABlE IF EXISTS users; DROP TABLE IF EXISTS migration_info; END$$;"#, diff --git a/src/db/mod.rs b/src/db/mod.rs index a2c056f..f32c997 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -7,24 +7,28 @@ mod migrations; use { deadpool_postgres::{CreatePoolError, Pool, Runtime}, - thiserror::Error, tokio_postgres::NoTls, + std::ops::Deref }; /// Errors that may occur during module initialization -#[derive(Error, Debug)] +#[derive(thiserror::Error, Debug)] pub enum InitialisationError { #[error("Could not initialize DB connection pool: {}", .0.to_string())] ConnectionPoolError(#[from] CreatePoolError), } /// Errors that may occur during normal app operation -#[derive(Error, Debug)] +#[derive(thiserror::Error, Debug)] pub enum Error { #[error("{}", .0.to_string())] - PoolError(#[from] deadpool_postgres::PoolError), + Pool(#[from] deadpool_postgres::PoolError), + #[error("{}", .0.to_string())] - DbError(#[from] tokio_postgres::Error), + Postgres(#[from] tokio_postgres::Error), + + #[error("Not allowed.")] + NotAllowed } /// Object that manages the database. @@ -35,6 +39,25 @@ pub struct Database { connection_pool: Pool, } +#[derive(Debug)] +pub struct UserId(i32); + +#[derive(Debug)] +pub struct User { + id: UserId, + pub real_name: String, +} + +pub struct PasswordHash(pub String); + +impl Deref for PasswordHash { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + impl Database { /// Create a connection pool and return the [Database]. pub fn new(connection_url: &str) -> Result { @@ -51,4 +74,71 @@ impl Database { pub async fn migrate_to_current_version(&self) -> Result<(), Error> { migrations::migrate_to_current_version(self).await } + + async fn get_client(&self) -> Result { + Ok(self.connection_pool.get().await?) + } + + pub async fn has_admin_users(&self) -> Result { + let client = self.get_client().await?; + Ok(client + .query_one("SELECT EXISTS(SELECT 1 FROM admin_users);", &[]) + .await? + .get(0)) + } + + pub async fn create_user( + &self, + real_name: &str, + email: &str, + password: &PasswordHash, + ) -> Result { + let client = self.get_client().await?; + let id = client + .query_one( + r#" + INSERT INTO users + (real_name, email, password) + VALUES $1, $1, $3, $4 + RETURNING id;"#, + &[&real_name, &email, &password.0], + ) + .await? + .get(0); + Ok(User { + id: UserId(id), + real_name: real_name.to_string(), + }) + } + + pub async fn get_password_for_user(&self, user: &User) -> Result { + let client = self.get_client().await?; + let row = client + .query_one( + "SELECT password FROM users WHERE id = $1;", + &[&user.id.0], + ) + .await?; + Ok(PasswordHash(row.get(0))) + } + + pub async fn create_first_admin_user( + &self, + real_name: &str, + email: &str, + password: &PasswordHash, + ) -> Result { + if self.has_admin_users().await? { + return Err(Error::NotAllowed) + } + let user = self.create_user(real_name, email, password).await?; + let client = self.get_client().await?; + client + .execute("INSERT INTO admin_users (id) VALUES $1", &[&user.id.0]) + .await?; + Ok(User { + id: user.id, + real_name: user.real_name, + }) + } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9fd2222 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,34 @@ +use { + crate::{authentication, db}, + askama_axum::{IntoResponse, Response}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Database Error: {}", 0.to_string())] + DatabaseError(#[from] db::Error), + + #[error("Authentication error")] + AuthenticationError(#[from] authentication::AuthenticationError) +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Error::DatabaseError(db::Error::Pool(pool_error)) => { + eprintln!("Database connection pool error: {}", pool_error); + todo!() + } + Error::DatabaseError(db::Error::Postgres(postgres_error)) => { + eprintln!("Database error: {}", postgres_error); + todo!() + } + Error::DatabaseError(db::Error::NotAllowed) => { + todo!() + } + Error::AuthenticationError(_) => { + todo!() + } + } + } +} diff --git a/src/main.rs b/src/main.rs index ba1ee03..c6b9c0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ use {thiserror::Error, tower_http::services::ServeDir}; +mod admin; +mod authentication; mod app; mod config; mod db; +mod error; use config::get_config; use db::Database; @@ -38,6 +41,7 @@ async fn locality_main() -> Result<(), Error> { db_pool.migrate_to_current_version().await.unwrap(); let app = app::routes() + .nest("/admin", admin::routes()) .with_state(db_pool) .nest_service("/static", ServeDir::new(&config.static_file_path)); diff --git a/templates/admin/create_first_user.html b/templates/admin/create_first_user.html new file mode 100644 index 0000000..9befb98 --- /dev/null +++ b/templates/admin/create_first_user.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}First-time Setup - Locality Administration{% endblock %} + +{% block content %} +

Please create an administrator account:

+
> +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + +
  • +
+
+{% endblock %} diff --git a/templates/admin/first_login.html b/templates/admin/first_login.html new file mode 100644 index 0000000..d8a9fde --- /dev/null +++ b/templates/admin/first_login.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block title %}First-time Setup - Locality Administration{% endblock %} + +{% block content %} +TODO: first_login.html +{% endblock %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..d9e91df --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block title %}Locality Administration{% endblock %} + +{% block content %}admin{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..95dc436 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}{{title}} - Locality{% endblock %} + + + + + + + + + + + + {% block head %}{% endblock %} + + + {% block content %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html index 56f24a3..fbfff69 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,21 +1,3 @@ - - - - - - {{title}} +{% extends "base.html" %} - - - - - - - - - - - - It still works! - - +{% block content %}It still works!{% endblock %}