wip: login, auth working, still need refactoring

This commit is contained in:
Gabor Körber 2024-07-16 07:27:26 +02:00
parent c78e386645
commit 402585f968
16 changed files with 127 additions and 51 deletions

1
Cargo.lock generated
View File

@ -2478,6 +2478,7 @@ dependencies = [
"axum-login", "axum-login",
"axum-messages", "axum-messages",
"blake3", "blake3",
"chrono",
"entity", "entity",
"log", "log",
"password-auth", "password-auth",

View File

@ -3,18 +3,17 @@ name = "entity"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
publish = false publish = false
default-run = "entity"
[lib] [lib]
name = "entity" name = "entity"
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tokio = { version = "1.32.0", features = ["full"] } tokio = { version = "1.32.0", features = ["full"] }
[dependencies.sea-orm] [dependencies.sea-orm]
version = "0.12.10" # sea-orm version version = "0.12.10" # sea-orm version
features = [ features = ["runtime-tokio-native-tls", "sqlx-postgres"]
"runtime-tokio-native-tls",
"sqlx-postgres",
]

View File

@ -0,0 +1,43 @@
use sea_orm::{
ConnectionTrait, Database, DatabaseConnection, DbConn, EntityTrait, Schema, Statement,
};
use entity::{group, group_permission, permission, user, user_group, user_permission};
async fn drop_table<E>(db: &DatabaseConnection, entity: E)
where
E: EntityTrait,
{
let table_name = entity.table_name();
let sql = format!("DROP TABLE IF EXISTS {} CASCADE;", table_name.to_string());
println!("{}", sql);
let stmt = Statement::from_string(db.get_database_backend(), sql);
match db.execute(stmt).await {
Ok(_) => println!("Dropped table {}", table_name),
Err(e) => println!("Error dropping table {}: {}", table_name, e),
}
}
pub async fn drop_tables(db: &DbConn) {
drop_table(db, user::Entity).await;
drop_table(db, permission::Entity).await;
drop_table(db, group::Entity).await;
drop_table(db, user_permission::Entity).await;
drop_table(db, group_permission::Entity).await;
drop_table(db, user_group::Entity).await;
}
#[tokio::main]
async fn main() {
// Running Entities manually creates the tables from the entities in their latest incarnation.
println!("Connecting to database...");
let db: DatabaseConnection =
Database::connect("postgresql://miniweb:miniweb@localhost:54321/miniweb")
.await
.unwrap();
println!("Dropping tables for entities...");
drop_tables(&db).await;
}

View File

@ -21,7 +21,7 @@ where
let stmt = backend.build(&*table_create_statement); let stmt = backend.build(&*table_create_statement);
match db.execute(stmt).await { match db.execute(stmt).await {
Ok(_) => println!("Migrated {}", entity.table_name()), Ok(_) => println!("Created {}", entity.table_name()),
Err(e) => println!("Error: {}", e), Err(e) => println!("Error: {}", e),
} }
} }

View File

@ -1,4 +1,5 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::ActiveValue::{NotSet, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::group::Entity as Group; use super::group::Entity as Group;
@ -12,7 +13,7 @@ pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
pub id: i64, pub id: i64,
#[sea_orm(index = "user_usernames")] #[sea_orm(index = "user_usernames", unique)]
pub username: String, pub username: String,
pub password: String, pub password: String,
@ -32,7 +33,16 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {} pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
is_active: Set(true),
is_staff: Set(false),
is_superuser: Set(false),
..<Self as ActiveModelTrait>::default()
}
}
}
impl Related<Group> for Entity { impl Related<Group> for Entity {
// The final relation is User -> UserGroup -> Group // The final relation is User -> UserGroup -> Group

View File

@ -2,7 +2,3 @@ pub struct User {
pub id: i32, pub id: i32,
pub username: String, pub username: String,
} }
pub struct NewUser<'a> {
pub username: &'a str,
}

View File

@ -28,3 +28,4 @@ async-trait = "0.1.80"
password-auth = "1.0.0" password-auth = "1.0.0"
thiserror = "1.0.61" thiserror = "1.0.61"
blake3 = "1.5.1" blake3 = "1.5.1"
chrono = "0.4"

View File

@ -1,3 +1,4 @@
pub mod models; pub mod models;
pub mod user_admin_repository; pub mod user_admin_repository;
pub mod utils;
pub mod views; pub mod views;

View File

@ -5,11 +5,16 @@ use axum_login::{AuthUser, AuthzBackend};
use axum_login::{AuthnBackend, UserId}; use axum_login::{AuthnBackend, UserId};
use log::debug; use log::debug;
use password_auth::{generate_hash, is_hash_obsolete, verify_password}; use password_auth::{generate_hash, is_hash_obsolete, verify_password};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter}; use sea_orm::{
ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait,
QueryFilter,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio::task; use tokio::task;
use crate::utils::get_current_timestamp;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UserRepository { pub struct UserRepository {
pub(crate) connection: DatabaseConnection, pub(crate) connection: DatabaseConnection,
@ -35,6 +40,17 @@ impl UserRepository {
// As checking for obsoleteness errored out, we assume this a raw password. // As checking for obsoleteness errored out, we assume this a raw password.
generate_hash(password) generate_hash(password)
} }
pub fn new_user(username: &str, password: &str) -> entity::user::ActiveModel {
entity::user::ActiveModel {
username: sea_orm::ActiveValue::Set(username.to_owned()),
password: sea_orm::ActiveValue::Set(UserRepository::encode_password(
password.to_owned(),
)),
date_joined: sea_orm::ActiveValue::Set(Some(get_current_timestamp())),
..<entity::user::ActiveModel as ActiveModelTrait>::default()
}
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@ -146,21 +162,23 @@ impl AuthnBackend for UserRepository {
.one(&self.connection) .one(&self.connection)
.await?; .await?;
let user = if let Some(user) = user_found { if let Some(user) = user_found {
let rear_user: AuthenticatedUser = user.into(); let given_password = creds.password.clone();
Some(rear_user) let user_password = user.password.clone();
} else { let verified =
None task::spawn_blocking(move || verify_password(&given_password, &user_password))
}; .await?;
// Verifying the password is blocking and potentially slow, so we'll do so via if verified.is_ok() {
// `spawn_blocking`. let mut db_user: entity::user::ActiveModel = user.into();
task::spawn_blocking(|| { db_user.last_login = ActiveValue::Set(Some(get_current_timestamp()));
// We're using password-based authentication--this works by comparing our form let user = db_user.update(&self.connection).await?;
// input with an argon2 password hash. let rear_user: AuthenticatedUser = user.into();
Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok())) return Ok(Some(rear_user));
}) }
.await? }
Ok(None) // No user found or verification failed
} }
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> { async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {

View File

@ -26,6 +26,7 @@ impl AdminRepository for UserRepository {
display_list: &["id", "username"], display_list: &["id", "username"],
fields: &[ fields: &[
"username", "username",
"password",
"first_name", "first_name",
"last_name", "last_name",
"email", "email",
@ -35,7 +36,8 @@ impl AdminRepository for UserRepository {
], ],
// fields_readonly: &["last_login", "date_joined"] // fields_readonly: &["last_login", "date_joined"]
} }
.into() .build()
.set_widget("password", Widget::default().as_password())
} }
async fn get(&self, model: &RepositoryContext, id: &Self::Key) -> Option<RepositoryItem> { async fn get(&self, model: &RepositoryContext, id: &Self::Key) -> Option<RepositoryItem> {
@ -83,14 +85,11 @@ impl AdminRepository for UserRepository {
async fn create(&mut self, model: &RepositoryContext, data: Value) -> Option<RepositoryItem> { async fn create(&mut self, model: &RepositoryContext, data: Value) -> Option<RepositoryItem> {
if let Value::Object(data) = data { if let Value::Object(data) = data {
let username = data.get("username").unwrap().as_str().unwrap(); let username = data.get("username").unwrap().as_str().unwrap();
let password = data.get("password").unwrap().as_str().unwrap();
let mut user = entity::user::ActiveModel { let mut user = UserRepository::new_user(username, password);
username: Set(username.to_owned()),
..Default::default()
};
let keys = [ let keys = [
"password",
"first_name", "first_name",
"last_name", "last_name",
"email", "email",
@ -102,16 +101,11 @@ impl AdminRepository for UserRepository {
for key in &keys { for key in &keys {
if let Some(value) = data.get(*key) { if let Some(value) = data.get(*key) {
match *key { match *key {
"password" => {
user.password = Set(UserRepository::encode_password(
value.as_str().unwrap().to_owned(),
))
}
"first_name" => user.first_name = Set(value.as_str().map(|s| s.to_owned())), "first_name" => user.first_name = Set(value.as_str().map(|s| s.to_owned())),
"last_name" => user.last_name = Set(value.as_str().map(|s| s.to_owned())), "last_name" => user.last_name = Set(value.as_str().map(|s| s.to_owned())),
"email" => user.email = Set(value.as_str().map(|s| s.to_owned())), "email" => user.email = Set(value.as_str().map(|s| s.to_owned())),
"is_staff" => user.is_staff = Set(value.as_bool().unwrap_or(false)), "is_staff" => user.is_staff = Set(value.as_bool().unwrap_or(false)),
"is_active" => user.is_active = Set(value.as_bool().unwrap_or(false)), "is_active" => user.is_active = Set(value.as_bool().unwrap_or(true)),
"is_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)), "is_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)),
_ => (), _ => (),
} }

8
rear_auth/src/utils.rs Normal file
View File

@ -0,0 +1,8 @@
use sea_orm::prelude::DateTimeWithTimeZone;
pub fn get_current_timestamp() -> DateTimeWithTimeZone {
let utc_now = chrono::Utc::now();
// Apply the timezone offset to the UTC time: FixedOffset::east(offset_hours * 3600);
let local_time = utc_now;
local_time.into()
}

View File

@ -70,7 +70,7 @@ mod post {
pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse { pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
match auth_session.logout().await { match auth_session.logout().await {
Ok(_) => Redirect::to("/login").into_response(), Ok(_) => Redirect::to("/admin/login").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
} }
} }

View File

@ -1,5 +1,5 @@
use dotenvy::dotenv; use dotenvy::dotenv;
use entity; use rear_auth::models::UserRepository;
use sea_orm::{ActiveModelTrait, Database, DatabaseConnection, DbConn, DbErr, Set}; use sea_orm::{ActiveModelTrait, Database, DatabaseConnection, DbConn, DbErr, Set};
use std::env; use std::env;
use std::io::stdin; use std::io::stdin;
@ -11,11 +11,12 @@ pub async fn establish_connection() -> DatabaseConnection {
db.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) db.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
} }
pub async fn create_user(conn: &DbConn, username: &str) -> Result<entity::user::Model, DbErr> { pub async fn create_user(
let user = entity::user::ActiveModel { conn: &DbConn,
username: Set(username.to_owned()), username: &str,
..Default::default() password: &str,
}; ) -> Result<entity::user::Model, DbErr> {
let user = UserRepository::new_user(username, password);
let user = user.insert(conn).await?; let user = user.insert(conn).await?;
Ok(user) Ok(user)
} }
@ -25,12 +26,16 @@ async fn main() {
let connection = establish_connection().await; let connection = establish_connection().await;
let mut username = String::new(); let mut username = String::new();
let mut password = String::new();
println!("Enter username for new user:"); println!("Enter username for new user:");
stdin().read_line(&mut username).unwrap(); stdin().read_line(&mut username).unwrap();
let username = username.trim_end(); // Remove the trailing newline let username = username.trim_end(); // Remove the trailing newline
println!("Enter password for new user:");
stdin().read_line(&mut password).unwrap();
let password = password.trim_end(); // Remove the trailing newline
let user = create_user(&connection, username) let user = create_user(&connection, username, password)
.await .await
.expect("Error creating user"); .expect("Error creating user");
println!("\nSaved user {} with id {}", username, user.id); println!("\nSaved user {} with id {}", username, user.id);

View File

@ -21,7 +21,7 @@ async fn main() {
println!("Displaying {} users", results.len()); println!("Displaying {} users", results.len());
for user in results { for user in results {
println!("{}", user.username); println!("{} - {}", user.username, user.password);
} }
println!("-----------\n"); println!("-----------\n");
} }

View File

@ -4,7 +4,7 @@
<div class="ui container"> <div class="ui container">
<h1>Update {{item_model.name}} in {{item_info.name}}</h1> <h1>Update {{item_model.name}} in {{item_info.name}}</h1>
{% set fields = item_info.fields %} {% set fields = item_info.fields %}
{% set form = { 'action': item.change_url } %} {% set form = { 'action': item.change_url, 'method': 'PATCH' } %}
{% include "/admin/items/item_change_form.jinja" %} {% include "/admin/items/item_change_form.jinja" %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -24,11 +24,11 @@
<legend>User login</legend> <legend>User login</legend>
<p> <p>
<label for="username">Username</label> <label for="username">Username</label>
<input name="username" id="username" value="ferris" /> <input name="username" id="username" value="admin" />
</p> </p>
<p> <p>
<label for="password">Password</label> <label for="password">Password</label>
<input name="password" id="password" type="password" value="hunter42" /> <input name="password" id="password" type="password" value="admin" />
</p> </p>
</fieldset> </fieldset>