WIP User authentication and first-time set-up
Also other smaller things.
This commit is contained in:
parent
a2887ecc36
commit
54350e3919
|
|
@ -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"
|
||||
argon2 = { version = "0.5", features = ["password-hash", "std"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
@ -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(
|
||||
¶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 {})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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$$;"#,
|
||||
|
|
|
|||
100
src/db/mod.rs
100
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<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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}First-time Setup - Locality Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
TODO: first_login.html
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Locality Administration{% endblock %}
|
||||
|
||||
{% block content %}admin{% endblock %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue