Merge remote-tracking branch 'refs/remotes/origin/rear_auth' into rear_auth

This commit is contained in:
Gabor Körber
2026-05-06 19:32:48 +02:00
64 changed files with 1565 additions and 1274 deletions
+1
View File
@@ -28,3 +28,4 @@ async-trait = "0.1.80"
password-auth = "1.0.0"
thiserror = "1.0.61"
blake3 = "1.5.1"
chrono = "0.4"
+1
View File
@@ -1,3 +1,4 @@
pub mod models;
pub mod user_admin_repository;
pub mod utils;
pub mod views;
+33 -15
View File
@@ -5,11 +5,16 @@ use axum_login::{AuthUser, AuthzBackend};
use axum_login::{AuthnBackend, UserId};
use log::debug;
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 thiserror::Error;
use tokio::task;
use crate::utils::get_current_timestamp;
#[derive(Debug, Clone)]
pub struct UserRepository {
pub(crate) connection: DatabaseConnection,
@@ -35,6 +40,17 @@ impl UserRepository {
// As checking for obsoleteness errored out, we assume this a raw 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)]
@@ -146,21 +162,23 @@ impl AuthnBackend for UserRepository {
.one(&self.connection)
.await?;
let user = if let Some(user) = user_found {
let rear_user: AuthenticatedUser = user.into();
Some(rear_user)
} else {
None
};
if let Some(user) = user_found {
let given_password = creds.password.clone();
let user_password = user.password.clone();
let verified =
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
// `spawn_blocking`.
task::spawn_blocking(|| {
// We're using password-based authentication--this works by comparing our form
// input with an argon2 password hash.
Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok()))
})
.await?
if verified.is_ok() {
let mut db_user: entity::user::ActiveModel = user.into();
db_user.last_login = ActiveValue::Set(Some(get_current_timestamp()));
let user = db_user.update(&self.connection).await?;
let rear_user: AuthenticatedUser = user.into();
return Ok(Some(rear_user));
}
}
Ok(None) // No user found or verification failed
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
+76 -50
View File
@@ -1,14 +1,13 @@
use async_trait::async_trait;
use log::debug;
use rear::admin::domain::*;
use rear::admin::state::AdminRegistry;
use log::{debug, warn};
use rear::depot::prelude::*;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ModelTrait, Set};
use serde_json::Value;
use crate::models::UserRepository;
#[async_trait]
impl AdminRepository for UserRepository {
impl DepotRepository for UserRepository {
type Key = i64;
fn key_from_string(&self, s: String) -> Option<Self::Key> {
@@ -26,6 +25,7 @@ impl AdminRepository for UserRepository {
display_list: &["id", "username"],
fields: &[
"username",
"password",
"first_name",
"last_name",
"email",
@@ -35,28 +35,35 @@ impl AdminRepository for UserRepository {
],
// fields_readonly: &["last_login", "date_joined"]
}
.into()
.build()
.set_widget(
"password",
Widget::widget("/depot/widgets/password_change.jinja").as_password(),
)
.set_widget("is_staff", Widget::checkbox())
.set_widget("is_active", Widget::checkbox())
.set_widget("is_superuser", Widget::checkbox())
}
async fn get(&self, model: &RepositoryContext, id: &Self::Key) -> Option<RepositoryItem> {
async fn get(&self, model: &RepositoryContext, id: &Self::Key) -> RepositoryResult {
let id: i32 = *id as i32; // use try_into() instead.
let get_user = entity::User::find_by_id(id).one(&self.connection).await;
match get_user {
Ok(get_user) => {
if let Some(user) = get_user {
let id = user.id.to_string();
match serde_json::to_value(&user) {
Ok(item) => {
return Some(model.build_item(&*id, item));
}
Err(_) => return None,
if let Ok(get_user) = get_user {
if let Some(user) = get_user {
let id = user.id.to_string();
match serde_json::to_value(&user) {
Ok(item) => {
return Ok(RepositoryResponse::ItemOnly(model.build_item(&*id, item)));
}
Err(_) => {
return Err(RepositoryError::UnknownError(
"JSON Error creating value".to_owned(),
))
}
}
}
Err(_) => return None,
}
None
Ok(RepositoryResponse::NoItem)
}
async fn list(&self, model: &RepositoryContext) -> RepositoryList {
@@ -80,17 +87,14 @@ impl AdminRepository for UserRepository {
}
}
async fn create(&mut self, model: &RepositoryContext, data: Value) -> Option<RepositoryItem> {
async fn create(&mut self, model: &RepositoryContext, data: Value) -> RepositoryResult {
if let Value::Object(data) = data {
let username = data.get("username").unwrap().as_str().unwrap();
let password = data.get("password").unwrap().as_str().unwrap();
let mut user = entity::user::ActiveModel {
username: Set(username.to_owned()),
..Default::default()
};
let mut user = UserRepository::new_user(username, password);
let keys = [
"password",
"first_name",
"last_name",
"email",
@@ -102,16 +106,11 @@ impl AdminRepository for UserRepository {
for key in &keys {
if let Some(value) = data.get(*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())),
"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())),
"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)),
_ => (),
}
@@ -120,10 +119,11 @@ impl AdminRepository for UserRepository {
if let Ok(user) = user.insert(&self.connection).await {
let id = user.id.to_string();
return Some(model.build_item(&*id, serde_json::to_value(&user).unwrap()));
let item = model.build_item(&*id, serde_json::to_value(&user).unwrap());
return Ok(RepositoryResponse::ItemOnly(item));
}
}
None
Ok(RepositoryResponse::NoItem)
}
async fn update(
@@ -131,32 +131,57 @@ impl AdminRepository for UserRepository {
model: &RepositoryContext,
id: &Self::Key,
data: Value,
) -> Option<RepositoryItem> {
) -> RepositoryResult {
let id: i32 = *id as i32;
let user: Option<entity::user::Model> = entity::User::find_by_id(id)
.one(&self.connection)
.await
.unwrap();
let mut user: entity::user::ActiveModel = user.unwrap().into();
.map_err(|e| RepositoryError::DatabaseError(Box::new(e)))?;
let mut user: entity::user::ActiveModel = user.ok_or(RepositoryError::ItemNotFound)?.into();
// change values
// should we really allow username change?
if let Some(value) = data.get("username") {
if let Some(value) = value.as_str() {
user.username = Set(value.to_owned());
}
}
if let Some(value) = data.get("password") {
user.password = Set(value.as_str().unwrap().to_owned());
let keys = [
"first_name",
"last_name",
"email",
"is_staff",
"is_active",
"is_superuser",
];
for key in &keys {
if let Some(value) = data.get(*key) {
match *key {
"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())),
"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_active" => user.is_active = Set(value.as_bool().unwrap_or(true)),
"is_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)),
_ => (),
}
}
}
// update
if let Ok(user) = user.update(&self.connection).await {
let id = user.id.to_string();
return Some(model.build_item(&*id, serde_json::to_value(&user).unwrap()));
match user.update(&self.connection).await {
Ok(user) => {
let id = user.id.to_string();
return Ok(RepositoryResponse::ItemOnly(
model.build_item(&*id, serde_json::to_value(&user).unwrap()),
));
}
Err(err) => {
warn!("Error updating user");
return Err(RepositoryError::DatabaseError(Box::new(err)));
}
}
None
}
async fn replace(
@@ -164,7 +189,7 @@ impl AdminRepository for UserRepository {
model: &RepositoryContext,
id: &Self::Key,
data: Value,
) -> Option<RepositoryItem> {
) -> RepositoryResult {
self.update(model, id, data).await
}
@@ -176,6 +201,7 @@ impl AdminRepository for UserRepository {
.unwrap();
if let Some(user) = user {
let delete_result = user.delete(&self.connection).await.unwrap();
// .ok_or(RepositoryError::DatabaseError(Box::new(err)))?;
debug!("deleted rows: {}", delete_result.rows_affected);
}
@@ -183,16 +209,16 @@ impl AdminRepository for UserRepository {
}
}
pub fn register(registry: &mut AdminRegistry, db: DatabaseConnection) {
let app_key = registry.register_app("Auth");
pub fn register(registry: &mut DepotRegistry, db: DatabaseConnection) -> UserRepository {
let section_key = registry.register_section("Auth");
let repo = UserRepository::new(db);
let model_config = AdminModelConfig {
app_key: app_key,
let model_config = DepotModelConfig {
section_key: section_key,
name: "User".to_owned(),
};
let model_result = registry.register_model(model_config, repo);
let model_result = registry.register_model(model_config, repo.clone());
match model_result {
Err(err) => panic!("{}", err),
_ => (),
_ => repo,
}
}
+8
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()
}
+16 -15
View File
@@ -6,7 +6,6 @@ use axum::{
Form, Router,
};
use axum_messages::{Message, Messages};
use rear::service::templates::Templates;
use serde::{Deserialize, Serialize};
use crate::models::{AuthSession, Credentials};
@@ -24,11 +23,12 @@ pub struct NextUrl {
next: Option<String>,
}
pub fn router() -> Router<Templates> {
pub fn routes<S: rear::depot::state::DepotState + Clone + Send + Sync + 'static>() -> Router<S>
where {
Router::new()
.route("/login", post(self::post::login))
.route("/login", get(self::get::login))
.route("/logout", get(self::get::logout))
.route("/login", get(self::get::login::<S>))
.route("/logout", post(self::post::logout))
}
mod post {
@@ -44,7 +44,7 @@ mod post {
Ok(None) => {
messages.error("Invalid credentials");
let mut login_url = "/login".to_string();
let mut login_url = "/depot/login".to_string();
if let Some(next) = creds.next {
login_url = format!("{}?next={}", login_url, next);
};
@@ -67,29 +67,30 @@ mod post {
}
.into_response()
}
pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => Redirect::to("/depot/login").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
mod get {
use super::*;
use axum::extract::State;
pub async fn login(
pub async fn login<S: rear::depot::state::DepotState + Clone + Send + Sync + 'static>(
messages: Messages,
templates: State<Templates>,
admin: State<S>,
Query(NextUrl { next }): Query<NextUrl>,
) -> impl IntoResponse {
let templates = admin.get_templates();
let context = LoginTemplate {
messages: messages.into_iter().collect(),
next,
};
templates.render_html("login.html", context)
}
pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => Redirect::to("/login").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
templates.render_html("depot/login.html", context)
}
}