WIP User authentication and first-time set-up

Also other smaller things.
This commit is contained in:
Matthew Gordon 2024-02-19 21:55:15 -04:00
parent a2887ecc36
commit 54350e3919
12 changed files with 403 additions and 48 deletions

View File

@ -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"
thiserror = "1.0"
argon2 = { version = "0.5", features = ["password-hash", "std"] }
serde = { version = "1.0", features = ["derive"] }

69
src/admin.rs Normal file
View File

@ -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<Database> {
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<Database>) -> Result<Response, Error> {
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<Database>,
Form(params): Form<CreateFirstUserParameters>,
) -> Result<FirstLoginTemplate, Error> {
let user = db
.create_first_admin_user(
&params.real_name,
&params.email,
&Password::new(&params.password)?.into(),
)
.await?;
if let Some(_user) = authenticate_user(&db, user, &params.password).await? {
// Store cookie and display configuration page
todo!();
} else {
// Report failure
todo!();
}
Ok(FirstLoginTemplate {})
}

95
src/authentication.rs Normal file
View File

@ -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<Password, AuthenticationError> {
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<bool, AuthenticationError> {
let hash = PasswordHash::new(&self.hash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok())
}
}
impl From<db::PasswordHash> for Password {
fn from(password: db::PasswordHash) -> Self {
Password {
hash: password.to_string(),
}
}
}
impl From<Password> 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<Option<AuthenticatedUser>, AuthenticationError> {
let password: Password = db.get_password_for_user(&user).await?.into();
Ok(if password.check(supplied_password)? {
Some(AuthenticatedUser { user })
} else {
None
})
}

View File

@ -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<i32, Error> {
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<i32, Error> {
&[&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<i32, Error> {
.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$$;"#,

View File

@ -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<Database, InitialisationError> {
@ -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<deadpool_postgres::Client, Error> {
Ok(self.connection_pool.get().await?)
}
pub async fn has_admin_users(&self) -> Result<bool, Error> {
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<User, Error> {
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<PasswordHash, Error> {
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<User, Error> {
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,
})
}
}

34
src/error.rs Normal file
View File

@ -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!()
}
}
}
}

View File

@ -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));

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}First-time Setup - Locality Administration{% endblock %}
{% block content %}
<h1>Please create an administrator account:</h1>
<form action="/create-initial-admin-user" method="post">>
<ul>
<li>
<label for="real_name">Name:</label>
<input type="text" id="real_name" name="real_name" />
</li>
<li>
<label for="email">Email:</label>
<input type="email" id="email" name="email" />
</li>
<li>
<label for="password">Password:</label>
<input type="password" id="password" name="password" />
</li>
<li>
<button type="submit">Create Account</button>
</li>
</ul>
</form>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}First-time Setup - Locality Administration{% endblock %}
{% block content %}
TODO: first_login.html
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block title %}Locality Administration{% endblock %}
{% block content %}admin{% endblock %}

23
templates/base.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{% block title %}{{title}} - Locality{% endblock %}</title>
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16.png">
<!-- For Google and Android -->
<link rel="icon" type="image/png" sizes="48x48" href="static/favicon-48.png">
<link rel="icon" type="image/png" sizes="192x192" href="static/favicon-192.png">
<!-- For iPad -->
<link rel="apple-touch-icon" type="image/png" sizes="167x167" href="static/favicon-167.png">
<!-- For iPhone -->
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="static/favicon-180.png">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@ -1,21 +1,3 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{{title}}</title>
{% extends "base.html" %}
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16.png">
<!-- For Google and Android -->
<link rel="icon" type="image/png" sizes="48x48" href="static/favicon-48.png">
<link rel="icon" type="image/png" sizes="192x192" href="static/favicon-192.png">
<!-- For iPad -->
<link rel="apple-touch-icon" type="image/png" sizes="167x167" href="static/favicon-167.png">
<!-- For iPhone -->
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="static/favicon-180.png">
</head>
<body>
It still works!
</body>
</html>
{% block content %}It still works!{% endblock %}