wip: implementing auth further

This commit is contained in:
Gabor Körber 2024-07-10 17:49:45 +02:00
parent e19d5db7c6
commit b029b4b975
11 changed files with 418 additions and 204 deletions

44
Cargo.lock generated
View File

@ -143,6 +143,12 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayref"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.4" version = "0.7.4"
@ -490,6 +496,23 @@ dependencies = [
"urlencoding", "urlencoding",
] ]
[[package]]
name = "axum-messages"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0d06050ddcb05eaaffe44c5f73443ea936eadae6a8332dd47b8c8c8cb1af98a"
dependencies = [
"async-trait",
"axum-core 0.4.3",
"http 1.0.0",
"parking_lot",
"serde",
"serde_json",
"tower",
"tower-sessions-core",
"tracing",
]
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.69" version = "0.3.69"
@ -576,6 +599,19 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "blake3"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cca6d3674597c30ddf2c587bf8d9d65c9a84d2326d941cc79c9842dfe0ef52"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -772,6 +808,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@ -2431,6 +2473,8 @@ dependencies = [
"async-trait", "async-trait",
"axum 0.7.4", "axum 0.7.4",
"axum-login", "axum-login",
"axum-messages",
"blake3",
"entity", "entity",
"log", "log",
"password-auth", "password-auth",

View File

@ -23,6 +23,8 @@ sea-orm = { version = "0.12.10", features = [
"sqlx-postgres", "sqlx-postgres",
] } ] }
axum-login = "0.15.3" axum-login = "0.15.3"
axum-messages = "0.6.1"
async-trait = "0.1.80" 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"

2
rear_auth/roadmap.md Normal file
View File

@ -0,0 +1,2 @@
Atm. it is not clear to me if rear_auth will really be it's own package, as it needs to interact a lot with rear, or if I split up rear into modules instead.

View File

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

View File

@ -1,3 +1 @@
mod users;
pub fn main() {} pub fn main() {}

230
rear_auth/src/models.rs Normal file
View File

@ -0,0 +1,230 @@
use std::collections::HashSet;
use async_trait::async_trait;
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 serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::task;
#[derive(Debug, Clone)]
pub struct UserRepository {
pub(crate) connection: DatabaseConnection,
}
impl UserRepository {
pub fn new(connection: DatabaseConnection) -> Self {
UserRepository {
connection: connection,
}
}
pub(crate) fn encode_password(password: String) -> String {
// This function will try to avoid re-encoding an encoded password.
// This is why it is not public outside of the crate.
if let Ok(_is_obsolete) = is_hash_obsolete(password.as_str()) {
// Not sure what to do if it is obsolete.
if _is_obsolete {
debug!("UserRepository::encode_password: found obsolete password hash.");
}
return password;
}
// As checking for obsoleteness errored out, we assume this a raw password.
generate_hash(password)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct AuthenticatedUser {
id: i64,
pub username: String,
password: String,
session_token: Vec<u8>, // Stores the hash
}
impl AuthenticatedUser {
fn new(id: i64, username: String, password: String) -> Self {
AuthenticatedUser {
id: id,
username: username,
session_token: blake3::hash(&password.as_bytes()).as_bytes().to_vec(),
password: password,
}
}
fn set_password(&mut self, new_password: String) {
self.password = new_password;
self.session_token = blake3::hash(self.password.as_bytes()).as_bytes().to_vec();
}
}
// Here we've implemented `Debug` manually to avoid accidentally logging the
// password hash.
impl std::fmt::Debug for AuthenticatedUser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password", &"[redacted]")
.finish()
}
}
impl AuthUser for AuthenticatedUser {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
&self.session_token
}
}
impl From<entity::user::Model> for AuthenticatedUser {
fn from(value: entity::user::Model) -> Self {
AuthenticatedUser::new(value.id, value.username, value.password)
}
}
// This allows us to extract the authentication fields from forms. We use this
// to authenticate requests with the backend.
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
pub next: Option<String>,
}
#[derive(Debug, Error)]
pub struct NotFoundError {
pub details: String,
}
impl NotFoundError {
pub fn new(details: &str) -> Self {
NotFoundError {
details: details.to_string(),
}
}
}
impl std::fmt::Display for NotFoundError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Not Found... Error: {}", self.details)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
DbErr(#[from] sea_orm::DbErr),
#[error("Not Found: {0}")]
NotFound(#[from] NotFoundError),
#[error(transparent)]
TaskJoin(#[from] task::JoinError),
}
#[async_trait]
impl AuthnBackend for UserRepository {
type User = AuthenticatedUser;
type Credentials = Credentials;
type Error = Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user_found = entity::User::find()
.filter(entity::user::Column::Username.eq(creds.username))
.one(&self.connection)
.await?;
let user = if let Some(user) = user_found {
let rear_user: AuthenticatedUser = user.into();
Some(rear_user)
} else {
None
};
// 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?
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user_found = entity::User::find_by_id(*user_id)
.one(&self.connection)
.await?;
if let Some(user) = user_found {
let rear_user: AuthenticatedUser = user.into();
Ok(Some(rear_user))
} else {
Ok(None)
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct Permission {
pub name: String,
}
impl From<&str> for Permission {
fn from(name: &str) -> Self {
Permission {
name: name.to_string(),
}
}
}
impl From<entity::permission::Model> for Permission {
fn from(model: entity::permission::Model) -> Self {
Permission {
name: model.codename,
}
}
}
#[async_trait]
impl AuthzBackend for UserRepository {
type Permission = Permission;
async fn get_group_permissions(
&self,
user: &Self::User,
) -> Result<HashSet<Self::Permission>, Self::Error> {
let user = entity::User::find_by_id(user.id)
.one(&self.connection)
.await?;
if let Some(user) = user {
let permissions = user
.find_related(entity::Permission)
.all(&self.connection)
.await?;
Ok(permissions
.into_iter()
.map(|item| Permission::from(item))
.collect())
} else {
Ok(HashSet::new())
}
}
}
// We use a type alias for convenience.
//
// Note that we've supplied our concrete backend here.
pub type AuthSession = axum_login::AuthSession<UserRepository>;

View File

@ -1,21 +1,11 @@
use crate::admin::domain::*;
use crate::admin::state::AdminRegistry;
use async_trait::async_trait; use async_trait::async_trait;
use log::debug; use log::debug;
use rear::admin::domain::*;
use rear::admin::state::AdminRegistry;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ModelTrait, Set}; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ModelTrait, Set};
use serde_json::Value; use serde_json::Value;
struct UserRepository { use crate::models::UserRepository;
connection: DatabaseConnection,
}
impl UserRepository {
pub fn new(connection: DatabaseConnection) -> Self {
UserRepository {
connection: connection,
}
}
}
#[async_trait] #[async_trait]
impl AdminRepository for UserRepository { impl AdminRepository for UserRepository {
@ -91,41 +81,47 @@ 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> {
let username = data.get("username").unwrap().as_str().unwrap(); if let Value::Object(data) = data {
let username = data.get("username").unwrap().as_str().unwrap();
let mut user = entity::user::ActiveModel { let mut user = entity::user::ActiveModel {
username: Set(username.to_owned()), username: Set(username.to_owned()),
..Default::default() ..Default::default()
}; };
let keys = [ let keys = [
"password", "password",
"first_name", "first_name",
"last_name", "last_name",
"email", "email",
"is_staff", "is_staff",
"is_active", "is_active",
"is_superuser", "is_superuser",
]; ];
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(value.as_str().unwrap().to_owned()), "password" => {
"first_name" => user.first_name = Set(value.as_str().map(|s| s.to_owned())), user.password = Set(UserRepository::encode_password(
"last_name" => user.last_name = Set(value.as_str().map(|s| s.to_owned())), value.as_str().unwrap().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)), "first_name" => user.first_name = Set(value.as_str().map(|s| s.to_owned())),
"is_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)), "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_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)),
_ => (),
}
} }
} }
}
if let Ok(user) = user.insert(&self.connection).await { if let Ok(user) = user.insert(&self.connection).await {
let id = user.id.to_string(); let id = user.id.to_string();
return Some(model.build_item(&*id, serde_json::to_value(&user).unwrap())); return Some(model.build_item(&*id, serde_json::to_value(&user).unwrap()));
}
} }
None None
} }

View File

@ -1,154 +0,0 @@
use async_trait::async_trait;
use axum_login::{AuthUser, AuthnBackend, UserId};
use password_auth::verify_password;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::task;
#[derive(Debug, Clone)]
pub struct UserDatabase {
connection: DatabaseConnection,
}
impl UserDatabase {
pub fn new(connection: DatabaseConnection) -> Self {
UserDatabase {
connection: connection,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct User {
id: i64,
pub username: String,
password: String,
}
// Here we've implemented `Debug` manually to avoid accidentally logging the
// password hash.
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password", &"[redacted]")
.finish()
}
}
impl AuthUser for User {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password.as_bytes() // We use the password hash as the auth
// hash--what this means
// is when the user changes their password the
// auth session becomes invalid.
}
}
impl From<entity::user::Model> for User {
fn from(value: entity::user::Model) -> Self {
User {
id: value.id,
username: value.username,
password: "".to_owned(), // TODO: password field.
}
}
}
// This allows us to extract the authentication fields from forms. We use this
// to authenticate requests with the backend.
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
pub next: Option<String>,
}
#[derive(Debug, Error)]
pub struct NotFoundError {
pub details: String,
}
impl NotFoundError {
pub fn new(details: &str) -> Self {
NotFoundError {
details: details.to_string(),
}
}
}
impl std::fmt::Display for NotFoundError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Not Found... Error: {}", self.details)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
DbErr(#[from] sea_orm::DbErr),
#[error("Not Found: {0}")]
NotFound(#[from] NotFoundError),
#[error(transparent)]
TaskJoin(#[from] task::JoinError),
}
#[async_trait]
impl AuthnBackend for UserDatabase {
type User = User;
type Credentials = Credentials;
type Error = Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user_found = entity::User::find()
.filter(entity::user::Column::Username.eq(creds.username))
.one(&self.connection)
.await?;
let user = if let Some(user) = user_found {
let rear_user: User = user.into();
Some(rear_user)
} else {
None
};
// 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?
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user_found = entity::User::find_by_id(*user_id)
.one(&self.connection)
.await?;
if let Some(user) = user_found {
let rear_user: User = user.into();
Ok(Some(rear_user))
} else {
Ok(None)
}
}
}
// We use a type alias for convenience.
//
// Note that we've supplied our concrete backend here.
pub type AuthSession = axum_login::AuthSession<UserDatabase>;

96
rear_auth/src/views.rs Normal file
View File

@ -0,0 +1,96 @@
use axum::{
extract::Query,
http::StatusCode,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use axum_messages::{Message, Messages};
use rear::service::templates::Templates;
use serde::{Deserialize, Serialize};
use crate::models::{AuthSession, Credentials};
#[derive(Serialize)]
pub struct LoginTemplate {
messages: Vec<Message>,
next: Option<String>,
}
// This allows us to extract the "next" field from the query string. We use this
// to redirect after log in.
#[derive(Debug, Deserialize)]
pub struct NextUrl {
next: Option<String>,
}
pub fn router() -> Router<Templates> {
Router::new()
.route("/login", post(self::post::login))
.route("/login", get(self::get::login))
.route("/logout", get(self::get::logout))
}
mod post {
use super::*;
pub async fn login(
mut auth_session: AuthSession,
messages: Messages,
Form(creds): Form<Credentials>,
) -> impl IntoResponse {
let user = match auth_session.authenticate(creds.clone()).await {
Ok(Some(user)) => user,
Ok(None) => {
messages.error("Invalid credentials");
let mut login_url = "/login".to_string();
if let Some(next) = creds.next {
login_url = format!("{}?next={}", login_url, next);
};
return Redirect::to(&login_url).into_response();
}
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
if auth_session.login(&user).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
messages.success(format!("Successfully logged in as {}", user.username));
if let Some(ref next) = creds.next {
Redirect::to(next)
} else {
Redirect::to("/")
}
.into_response()
}
}
mod get {
use super::*;
use axum::extract::State;
pub async fn login(
messages: Messages,
templates: State<Templates>,
Query(NextUrl { next }): Query<NextUrl>,
) -> impl IntoResponse {
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(),
}
}
}
// this was taken from the axum_login examples and modified. Redirection might need reverse-routing support.

View File

@ -1,4 +1,3 @@
pub mod empty_repository; pub mod empty_repository;
pub mod static_repository;
pub mod user_repository;
pub mod file_repository; pub mod file_repository;
pub mod static_repository;

View File

@ -5,9 +5,8 @@ mod howto;
mod state; mod state;
use crate::state::AppState; use crate::state::AppState;
use admin_examples::static_repository;
use admin_examples::user_repository;
use admin_examples::file_repository; use admin_examples::file_repository;
use admin_examples::static_repository;
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::MatchedPath, extract::MatchedPath,
@ -53,7 +52,7 @@ async fn main() {
// Register Admin Apps // Register Admin Apps
static_repository::register(&mut admin); static_repository::register(&mut admin);
user_repository::register(&mut admin, db_connection); //user_repository::register(&mut admin, db_connection);
file_repository::register(&mut admin, "static/admin"); file_repository::register(&mut admin, "static/admin");
// Create global Application State. // Create global Application State.