Compare commits

..

No commits in common. "b956bdbf05c40eb82aa6e0751c9537864bf56394" and "69af48bb62c95036b4b5ba6d40770e96ae22abb6" have entirely different histories.

87 changed files with 475 additions and 785 deletions

525
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,26 +3,26 @@ cargo-features = ["codegen-backend"]
[profile.dev]
codegen-backend = "cranelift"
[workspace]
members = [
"lib/kernel",
"lib/utils",
"lib/http_server",
"lib/admin_cli"
]
[package]
name = "minauthator"
description = "Identity provider and OAuth2 server for an small-scale organization."
version = "0.1.0"
edition = "2021"
[workspace.dependencies]
[dependencies]
# commons utils
anyhow = "1.0"
fully_pub = "0.1"
argon2 = "0.5"
strum = "0.26.3"
strum_macros = "0.26"
uuid = { version = "1.8", features = ["serde", "v4"] }
dotenvy = "0.15.7"
base64 = "0.22.1"
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["std"] }
url = "2.5.3"
# CLI
argh = "0.1"
argh = "0.1" # for CLI
# Async
tokio = { version = "1.40.0", features = ["rt-multi-thread"] }
@ -34,6 +34,7 @@ env_logger = "0.11"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_urlencoded = "0.7.1"
toml = "0.8"
chrono = { version = "0.4", features = ["serde"] }
@ -42,6 +43,21 @@ chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "chrono", "uuid"] }
redis = { version = "0.27.3", default-features = false, features = ["acl"] }
# Web
axum = { version = "0.7.7", features = ["json", "multipart"] }
axum-extra = { version = "0.9.4", features = ["cookie"] }
axum-template = { version = "2.4.0", features = ["minijinja"] }
axum_typed_multipart = "0.13.1"
minijinja = { version = "2.1", features = ["builtins"] }
# to make work the static assets server
tower-http = { version = "0.6.1", features = ["fs"] }
# Auth utils
totp-rs = "5.6"
minijinja-embed = "2.3.1"
axum-macros = "0.4.2"
jsonwebtoken = "9.3.0"
time = "0.3.36"
[build-dependencies]
minijinja-embed = "2.3.1"

View file

@ -3,7 +3,3 @@
https://datatracker.ietf.org/doc/html/rfc6749
https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template
## Oauth2 test
-> authorize

View file

@ -6,7 +6,7 @@ curl -v http://localhost:8085/authorize \
--cookie "tmp/.curl-cookies" \
-d client_id="a1785786-8be1-443c-9a6f-35feed703609" \
-d response_type="code" \
-d redirect_uri="http://localhost:9090/callback" \
-d redirect_uri="http://localhost:9090/authorize" \
-d scope="user_read_basic" \
-d state="qxYAfk4kf6pbZkms78jM"

View file

@ -1,12 +1,11 @@
export RUST_BACKTRACE := "1"
export RUST_LOG := "trace"
export RUN_ARGS := "run --bin minauthator-server -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets"
watch-run:
cargo-watch -x "$RUN_ARGS"
cargo-watch -x 'run -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets'
run:
cargo $RUN_ARGS
cargo run -- --database ./tmp/dbs/minauthator.db --config ./config.toml --static-assets ./assets
docker-run:
docker run -p 3085:8080 -v ./tmp/docker/config:/etc/minauthator -v ./tmp/docker/db:/var/lib/minauthator minauthator

View file

@ -1,11 +0,0 @@
[package]
name = "admin_cli"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
fully_pub = { workspace = true }
[[bin]]
name = "minauthator-admin"
path = "src/main.rs"

View file

@ -1,6 +0,0 @@
use anyhow::Result;
fn main() -> Result<()> {
println!("Starting minauthator admin CLI");
Ok(())
}

View file

@ -1,51 +0,0 @@
[package]
name = "http_server"
edition = "2021"
[dependencies]
kernel = { path = "../kernel" }
utils = { path = "../utils" }
# common
log = { workspace = true }
env_logger = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
anyhow = { workspace = true }
fully_pub = { workspace = true }
tokio = { workspace = true }
# Web
axum = { version = "0.7.7", features = ["json", "multipart"] }
axum-extra = { version = "0.9.4", features = ["cookie"] }
axum-template = { version = "2.4.0", features = ["minijinja"] }
axum_typed_multipart = "0.13.1"
minijinja = { version = "2.1", features = ["builtins"] }
# to make work the static assets server
tower-http = { version = "0.6.1", features = ["fs"] }
minijinja-embed = "2.3.1"
axum-macros = "0.4.2"
jsonwebtoken = "9.3.0"
time = "0.3.36"
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
chrono = { workspace = true }
argh = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
[build-dependencies]
minijinja-embed = "2.3.1"
[[bin]]
name = "minauthator-server"
path = "src/main.rs"

View file

@ -1,14 +0,0 @@
use axum::{extract::State, response::IntoResponse, Json};
use serde_json::json;
use crate::AppState;
pub async fn get_index(
State(app_state): State<AppState>,
) -> impl IntoResponse {
Json(json!({
"software": "Minauthator",
"name": app_state.config.instance.name,
"base_uri": app_state.config.instance.base_uri
}))
}

View file

@ -1,22 +0,0 @@
[package]
name = "kernel"
edition = "2021"
[dependencies]
utils = { path = "../utils" }
log = { workspace = true }
env_logger = { workspace = true }
anyhow = { workspace = true }
fully_pub = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
toml = { workspace = true }
sqlx = { workspace = true }
dotenvy = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }

View file

@ -1,4 +0,0 @@
pub const DEFAULT_DB_PATH: &str = "/var/lib/minauthator/minauthator.db";
pub const DEFAULT_ASSETS_PATH: &str = "/usr/local/lib/minauthator/assets";
pub const DEFAULT_CONFIG_PATH: &str = "/etc/minauthator/config.yaml";

View file

@ -1,51 +0,0 @@
use std::{env, fs};
use anyhow::{Result, Context, anyhow};
use fully_pub::fully_pub;
use log::info;
use sqlx::{Pool, Sqlite};
use crate::{
consts::{DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH}, database::prepare_database, models::config::Config, repositories::storage::Storage
};
/// get server config
fn get_config(path: String) -> Result<Config> {
let inp_def_yaml = fs::read_to_string(path)
.expect("Should have been able to read the the config file");
toml::from_str(&inp_def_yaml)
.map_err(|e| anyhow!("Failed to parse config, {:?}", e))
}
#[fully_pub]
struct StartKernelConfig {
config_path: Option<String>,
database_path: Option<String>,
}
#[derive(Debug, Clone)]
#[fully_pub]
struct AppSecrets {
jwt_secret: String
}
pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result<(Config, AppSecrets, Storage)> {
env_logger::init();
let _ = dotenvy::dotenv();
let database_path = &start_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string());
info!("Using database file at {}", database_path);
let storage = prepare_database(database_path).await.context("Could not prepare db.")?;
let config_path = start_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string());
info!("Using config file at {}", &config_path);
let config: Config = get_config(config_path)
.expect("Cannot get config.");
dotenvy::dotenv().context("loading .env")?;
let secrets = AppSecrets {
jwt_secret: env::var("APP_JWT_SECRET").context("Expecting APP_JWT_SECRET env var.")?
};
Ok((config, secrets, storage))
}

View file

@ -1,7 +0,0 @@
pub mod models;
pub mod database;
pub mod consts;
pub mod context;
pub mod actions;
pub mod repositories;

View file

@ -1,2 +0,0 @@
pub mod storage;
pub mod users;

View file

@ -1,7 +0,0 @@
use fully_pub::fully_pub;
use sqlx::{Pool, Sqlite};
/// storage interface
#[fully_pub]
#[derive(Clone, Debug)]
struct Storage(Pool<Sqlite>);

View file

@ -1,14 +0,0 @@
// user repositories
use crate::models::user::User;
use super::storage::Storage;
use anyhow::{Result, Context};
async fn get_user_by_id(storage: &Storage, user_id: &str) -> Result<User> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&storage.0)
.await
.context("To get user from claim")
}

View file

@ -1,10 +0,0 @@
[package]
name = "utils"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
argon2 = "0.5"
base64 = "0.22"
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["std"] }

View file

@ -1,9 +1,9 @@
use argh::FromArgs;
use anyhow::{Context, Result};
use http_server::{start_http_server, ServerConfig};
use kernel::{consts::DEFAULT_ASSETS_PATH, context::{get_kernel_context, StartKernelConfig}};
use log::info;
use crate::{get_app_context, server::{start_http_server, ServerConfig}, DEFAULT_ASSETS_PATH};
#[derive(Debug, FromArgs)]
/// Minauthator daemon
struct ServerCliFlags {
@ -27,15 +27,14 @@ struct ServerCliFlags {
listen_port: u32
}
/// handle CLI arguments to start HTTP server daemon
#[tokio::main]
pub async fn main() -> Result<()> {
/// handle CLI arguments to start process daemon
pub async fn start_server_cli() -> Result<()> {
info!("Starting minauth");
let flags: ServerCliFlags = argh::from_env();
let (config, secrets, db_pool) = get_kernel_context(StartKernelConfig {
let (config, secrets, db_pool) = get_app_context(crate::StartAppConfig {
config_path: flags.config,
database_path: flags.database
}).await.context("Getting kernel context")?;
}).await.context("Getting app context")?;
start_http_server(
ServerConfig {
assets_path: flags.static_assets.unwrap_or(DEFAULT_ASSETS_PATH.to_string()),

1
src/consts.rs Normal file
View file

@ -0,0 +1 @@
pub const WEB_GUI_JWT_COOKIE_NAME: &str = "minauthator_jwt";

View file

@ -1,4 +1,3 @@
pub mod index;
pub mod oauth2;
pub mod read_user;
pub mod openid;

View file

@ -4,9 +4,10 @@ use fully_pub::fully_pub;
use log::error;
use serde::{Deserialize, Serialize};
use kernel::models::authorization::Authorization;
use crate::{
services::{app_session::AppClientSession, session::create_token}, token_claims::AppUserTokenClaims, AppState
models::{authorization::Authorization, token_claims::AppUserTokenClaims},
server::AppState,
services::{app_session::AppClientSession, session::create_token}
};
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
@ -42,7 +43,7 @@ pub async fn get_access_token(
)
.bind(&form.code)
.bind(&app_client_session.client_id)
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await;
let authorization = match authorizations_res {
Ok(val) => val,

View file

@ -1,10 +1,9 @@
use axum::{extract::State, response::IntoResponse, Json};
use fully_pub::fully_pub;
use kernel::models::authorization::AuthorizationScope;
use serde::Serialize;
use strum::IntoEnumIterator;
use crate::AppState;
use crate::{models::authorization::AuthorizationScope, server::AppState};
#[derive(Serialize)]
#[fully_pub]

View file

@ -2,8 +2,7 @@ use axum::{extract::State, response::IntoResponse, Extension, Json};
use fully_pub::fully_pub;
use serde::Serialize;
use crate::{token_claims::AppUserTokenClaims, AppState};
use kernel::models::user::User;
use crate::{models::{token_claims::AppUserTokenClaims, user::User}, server::AppState};
#[derive(Serialize)]
#[fully_pub]
@ -20,10 +19,10 @@ pub async fn read_user_basic(
State(app_state): State<AppState>,
Extension(token_claims): Extension<AppUserTokenClaims>,
) -> impl IntoResponse {
// 1. This handler require app user authentification (JWT)
// 1. This handler require client user authentification (JWT)
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.user_id)
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");
let output = ReadUserBasicExtract {

View file

@ -1,10 +1,10 @@
use axum::{extract::State, response::IntoResponse, Extension};
use minijinja::context;
use kernel::models::{config::AppVisibility, config::Application};
use crate::{
models::{config::AppVisibility, config::Application},
renderer::TemplateRenderer,
AppState
server::AppState
};
pub async fn list_apps(

View file

@ -7,15 +7,14 @@ use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
use kernel::{
models::{authorization::Authorization, config::AppAuthorizeFlow}
};
use utils::get_random_alphanumerical;
use crate::{
renderer::TemplateRenderer, services::oauth2::{parse_scope, verify_redirect_uri}, token_claims::UserTokenClaims, AppState
models::{authorization::Authorization, config::AppAuthorizeFlow, token_claims::UserTokenClaims},
renderer::TemplateRenderer, server::AppState,
services::oauth2::{parse_scope, verify_redirect_uri},
utils::get_random_alphanumerical
};
#[derive(Debug, Serialize, Deserialize)]
#[derive(Serialize, Deserialize)]
#[fully_pub]
/// query params described in [RFC6749 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1)
struct AuthorizationParams {
@ -106,7 +105,7 @@ pub async fn authorize_form(
.bind(&token_claims.sub)
.bind(&authorization_params.client_id)
.bind(sqlx::types::Json(&scopes))
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await;
match authorizations_res {
@ -120,7 +119,7 @@ pub async fn authorize_form(
.bind(existing_authorization.id)
.bind(authorization_code.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.execute(&app_state.db)
.await.unwrap();
// Authorization already given, just redirect to the app
@ -220,7 +219,7 @@ pub async fn perform_authorize(
.bind(authorization.code)
.bind(authorization.last_used_at.map(|x| x.to_rfc3339_opts(SecondsFormat::Millis, true)))
.bind(authorization.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.execute(&app_state.db)
.await;
if let Err(err) = res {
error!("Failed to save authorization in DB. {}", err);

View file

@ -1,15 +1,15 @@
use axum_extra::extract::{cookie::{Cookie, SameSite}, CookieJar};
use chrono::{SecondsFormat, Utc};
use kernel::models::user::{User, UserStatus};
use log::info;
use serde::Deserialize;
use axum::{extract::{Query, State}, http::StatusCode, response::{IntoResponse, Redirect}, Extension, Form};
use axum::{extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse, Redirect}, Extension, Form};
use fully_pub::fully_pub;
use minijinja::context;
use time::Duration;
use utils::verify_password_hash;
use crate::{renderer::TemplateRenderer, services::session::create_token, token_claims::UserTokenClaims, AppState, WEB_GUI_JWT_COOKIE_NAME};
use crate::{
consts::WEB_GUI_JWT_COOKIE_NAME, models::{token_claims::UserTokenClaims, user::{User, UserStatus}}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token}
};
pub async fn login_form(
Extension(renderer): Extension<TemplateRenderer>
@ -47,7 +47,7 @@ pub async fn perform_login(
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
.bind(&login.login)
.bind(&login.login)
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await;
let password_hash = match &user_res {
@ -87,7 +87,7 @@ pub async fn perform_login(
let _result = sqlx::query("UPDATE users SET last_login_at = $2 WHERE id = $1")
.bind(user.id.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.execute(&app_state.db)
.await.unwrap();
let jwt_max_age = Duration::days(15);

View file

@ -1,7 +1,7 @@
use axum::response::{IntoResponse, Redirect};
use axum_extra::extract::CookieJar;
use crate::WEB_GUI_JWT_COOKIE_NAME;
use crate::consts::WEB_GUI_JWT_COOKIE_NAME;
pub async fn perform_logout(
cookies: CookieJar

View file

@ -5,11 +5,10 @@ use log::error;
use minijinja::context;
use crate::{
token_claims::UserTokenClaims,
models::{token_claims::UserTokenClaims, user::User},
renderer::TemplateRenderer,
AppState
server::AppState
};
use kernel::models::user::User;
pub async fn me_page(
State(app_state): State<AppState>,
@ -18,7 +17,7 @@ pub async fn me_page(
) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");
@ -39,7 +38,7 @@ pub async fn me_update_details_form(
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");
@ -80,12 +79,12 @@ pub async fn me_perform_update_details(
.bind(details_update.full_name)
.bind(details_update.website)
.bind(details_update.picture.contents.to_vec())
.execute(&app_state.db.0)
.execute(&app_state.db)
.await;
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");

View file

@ -7,10 +7,7 @@ use fully_pub::fully_pub;
use sqlx::types::Json;
use uuid::Uuid;
use crate::{renderer::TemplateRenderer, AppState};
use kernel::models::user::{User, UserStatus};
use utils::get_password_hash;
use crate::{models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::password::get_password_hash};
pub async fn register_form(
State(app_state): State<AppState>
@ -69,7 +66,7 @@ pub async fn perform_register(
.bind(user.roles)
.bind(user.password_hash)
.bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.execute(&app_state.db)
.await;
match res {
Err(err) => {
@ -96,7 +93,7 @@ pub async fn perform_register(
StatusCode::OK,
"pages/register",
context!(
success => true
success => true
)
)
}

View file

@ -4,8 +4,7 @@ use log::error;
use minijinja::context;
use serde::Deserialize;
use kernel::models::authorization::Authorization;
use crate::{renderer::TemplateRenderer, token_claims::UserTokenClaims, AppState};
use crate::{models::{authorization::Authorization, token_claims::UserTokenClaims}, renderer::TemplateRenderer, server::AppState};
pub async fn get_authorizations(
State(app_state): State<AppState>,
@ -14,7 +13,7 @@ pub async fn get_authorizations(
) -> impl IntoResponse {
let user_authorizations = sqlx::query_as::<_, Authorization>("SELECT * FROM authorizations WHERE user_id = $1")
.bind(&token_claims.sub)
.fetch_all(&app_state.db.0)
.fetch_all(&app_state.db)
.await
.expect("To get user authorization with user_id from claim");
renderer.render(
@ -38,7 +37,7 @@ pub async fn revoke_authorization(
) -> impl IntoResponse {
let delete_res = sqlx::query("DELETE FROM authorizations WHERE id = $1")
.bind(&form.authorization_id)
.execute(&app_state.db.0)
.execute(&app_state.db)
.await;
match delete_res {
Ok(_) => {},

View file

@ -1,10 +1,8 @@
use anyhow::{Context, Result};
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, ConnectOptions};
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, Pool, Sqlite, ConnectOptions};
use std::str::FromStr;
use crate::repositories::storage::Storage;
pub async fn prepare_database(sqlite_db_path: &str) -> Result<Storage> {
pub async fn prepare_database(sqlite_db_path: &str) -> Result<Pool<Sqlite>> {
let conn_str = format!("sqlite:{}", sqlite_db_path);
let pool = SqlitePoolOptions::new()
@ -16,6 +14,6 @@ pub async fn prepare_database(sqlite_db_path: &str) -> Result<Storage> {
.await
.context("could not connect to database_url")?;
Ok(Storage(pool))
Ok(pool)
}

62
src/main.rs Normal file
View file

@ -0,0 +1,62 @@
pub mod models;
pub mod controllers;
pub mod router;
pub mod server;
pub mod database;
pub mod cli;
pub mod utils;
pub mod services;
pub mod middlewares;
pub mod renderer;
pub mod consts;
use std::{env, fs};
use anyhow::{Result, Context, anyhow};
use database::prepare_database;
use log::info;
use sqlx::{Pool, Sqlite};
use models::config::{AppSecrets, Config};
pub const DEFAULT_DB_PATH: &str = "/var/lib/minauthator/minauthator.db";
pub const DEFAULT_ASSETS_PATH: &str = "/usr/local/lib/minauthator/assets";
pub const DEFAULT_CONFIG_PATH: &str = "/etc/minauthator/config.yaml";
fn get_config(path: String) -> Result<Config> {
let inp_def_yaml = fs::read_to_string(path)
.expect("Should have been able to read the the config file");
toml::from_str(&inp_def_yaml)
.map_err(|e| anyhow!("Failed to parse config, {:?}", e))
}
struct StartAppConfig {
config_path: Option<String>,
database_path: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
cli::start_server_cli().await
}
async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, AppSecrets, Pool<Sqlite>)> {
env_logger::init();
let _ = dotenvy::dotenv();
let database_path = &start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string());
info!("Using database file at {}", database_path);
let pool = prepare_database(database_path).await.context("Could not prepare db.")?;
let config_path = start_app_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string());
info!("Using config file at {}", &config_path);
let config: Config = get_config(config_path)
.expect("Cannot get config.");
dotenvy::dotenv().context("loading .env")?;
let secrets = AppSecrets {
jwt_secret: env::var("APP_JWT_SECRET").context("Expecting APP_JWT_SECRET env var.")?
};
Ok((config, secrets, pool))
}

View file

@ -5,12 +5,9 @@ use axum::{
response::{Html, IntoResponse, Response},
Extension
};
use utils::parse_basic_auth;
use crate::{
services::{app_session::AppClientSession, session::verify_token},
token_claims::AppUserTokenClaims,
AppState
models::token_claims::AppUserTokenClaims, server::AppState, services::{app_session::AppClientSession, session::verify_token}, utils::parse_basic_auth
};

View file

@ -1,5 +1,6 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension};
use crate::{renderer::TemplateRenderer, token_claims::UserTokenClaims, AppState};
use crate::{models::token_claims::UserTokenClaims, renderer::TemplateRenderer, server::AppState};
pub async fn renderer_middleware(
State(app_state): State<AppState>,

View file

@ -7,9 +7,7 @@ use axum::{
use axum_extra::extract::CookieJar;
use crate::{
services::session::verify_token,
token_claims::UserTokenClaims,
AppState, WEB_GUI_JWT_COOKIE_NAME
consts::WEB_GUI_JWT_COOKIE_NAME, models::token_claims::UserTokenClaims, server::AppState, services::session::verify_token
};

View file

@ -69,6 +69,7 @@ struct Config {
roles: Vec<Role>
}
#[derive(Debug, Clone)]
#[fully_pub]
struct AppSecrets {

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod user;
pub mod authorization;
pub mod token_claims;

View file

@ -1,9 +1,10 @@
use fully_pub::fully_pub;
use jsonwebtoken::get_current_timestamp;
use kernel::models::authorization::AuthorizationScope;
use serde::{Deserialize, Serialize};
use time::Duration;
use super::authorization::AuthorizationScope;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct UserTokenClaims {

View file

@ -1,11 +1,9 @@
use axum::{http::StatusCode, response::{Html, IntoResponse}};
use fully_pub::fully_pub;
use kernel::models::config::Config;
use log::error;
use minijinja::{context, Environment, Value};
use utils::encode_base64_picture;
use crate::token_claims::UserTokenClaims;
use crate::models::token_claims::UserTokenClaims;
#[derive(Debug, Clone)]
@ -45,14 +43,3 @@ impl TemplateRenderer {
}
}
pub fn build_templating_env(config: &Config) -> Environment<'static> {
let mut env = Environment::new();
minijinja_embed::load_templates!(&mut env);
env.add_global("gl", context! {
instance => config.instance
});
env.add_function("inline_picture", encode_base64_picture);
env
}

View file

@ -9,7 +9,7 @@ use crate::{
app_auth,
renderer::renderer_middleware
},
AppState, ServerConfig
server::{AppState, ServerConfig}
};
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
@ -43,8 +43,7 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
let api_user_routes = Router::new()
.route("/api/user", get(api::read_user::read_user_basic))
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware))
.route("/api", get(api::index::get_index));
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware));
let well_known_routes = Router::new()
.route("/.well-known/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration));

View file

@ -1,22 +1,24 @@
pub mod controllers;
pub mod router;
pub mod services;
pub mod middlewares;
pub mod renderer;
pub mod token_claims;
use base64::{prelude::BASE64_STANDARD, Engine};
use fully_pub::fully_pub;
use anyhow::{Result, Context};
use kernel::{context::AppSecrets, models::config::Config, repositories::storage::Storage};
use log::info;
use minijinja::Environment;
use minijinja::{context, Environment};
use sqlx::{Pool, Sqlite};
use crate::{models::config::{AppSecrets, Config}, router::build_router};
use crate::{
router::build_router,
renderer::build_templating_env
};
fn build_templating_env(config: &Config) -> Environment<'static> {
let mut env = Environment::new();
pub const WEB_GUI_JWT_COOKIE_NAME: &str = "minauthator_jwt";
minijinja_embed::load_templates!(&mut env);
env.add_global("gl", context! {
instance => config.instance
});
env.add_function("encode_b64str", |bin_val: Vec<u8>| {
BASE64_STANDARD.encode(bin_val)
});
env
}
#[derive(Debug)]
#[fully_pub]
@ -32,7 +34,7 @@ pub struct ServerConfig {
pub struct AppState {
secrets: AppSecrets,
config: Config,
db: Storage,
db: Pool<Sqlite>,
templating_env: Environment<'static>
}
@ -40,7 +42,7 @@ pub async fn start_http_server(
server_config: ServerConfig,
config: Config,
secrets: AppSecrets,
db_pool: Storage
db_pool: Pool<Sqlite>
) -> Result<()> {
// build state
let state = AppState {

View file

@ -1,3 +1,4 @@
pub mod password;
pub mod session;
pub mod oauth2;
pub mod app_session;

View file

@ -1,7 +1,7 @@
use std::str::FromStr;
use anyhow::{Result, Context};
use kernel::models::{authorization::AuthorizationScope, config::Application};
use crate::models::{authorization::AuthorizationScope, config::Application};
pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool {
app.allowed_redirect_uris

35
src/services/password.rs Normal file
View file

@ -0,0 +1,35 @@
use anyhow::{anyhow, Result};
use argon2::{
password_hash::{
rand_core::OsRng,
PasswordHash, PasswordHasher, PasswordVerifier, SaltString
},
Argon2
};
pub fn get_password_hash(password: String) -> Result<(String, String)> {
let salt = SaltString::generate(&mut OsRng);
// Argon2 with default params (Argon2id v19)
let argon2 = Argon2::default();
// Hash password to PHC string ($argon2id$v=19$...)
match argon2.hash_password(password.as_bytes(), &salt) {
Ok(val) => Ok((salt.to_string(), val.to_string())),
Err(_) => Err(anyhow!("Failed to process password."))
}
}
pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> {
let parsed_hash = match PasswordHash::new(&password_hash) {
Ok(val) => val,
Err(_) => {
return Err(anyhow!("Failed to parse password hash"));
}
};
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(()),
Err(_) => Err(anyhow!("Failed to verify password."))
}
}

View file

@ -1,7 +1,8 @@
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
use kernel::context::AppSecrets;
use crate::models::config::AppSecrets;
pub fn create_token<T: Serialize>(secrets: &AppSecrets, claims: T) -> String {

View file

@ -61,7 +61,3 @@ pub fn parse_basic_auth(header_value: &str) -> Result<(String, String)> {
))
}
pub fn encode_base64_picture(picture_bytes: Vec<u8>) -> String {
let encoded = BASE64_STANDARD.encode(picture_bytes);
return format!("data:image/*;base64,{}", encoded);
}

View file

@ -1,39 +0,0 @@
#!/usr/bin/sh
set -eou pipefail
scenario_name="$1"
project_root="$(dirname $(cargo locate-project | jq -r .root))"
scenario_dir="$project_root/tests/hurl_integration/$1"
scenario_tmp_dir_path="$project_root/tmp/tests/$scenario_name"
database_path="$project_root/tmp/tests/$scenario_name/minauthator.db"
echo "Starting scenario $scenario_name."
mkdir -p $scenario_tmp_dir_path
if [ -f $database_path ]; then
rm $database_path
fi
sqlite3 $database_path < $project_root/migrations/all.sql
export DB_PATH=$database_path
if [ -f $scenario_dir/init_db.sh ]; then
$scenario_dir/init_db.sh
fi
pkill -f $project_root/target/debug/minauthator-server &
sleep 0.1
$project_root/target/debug/minauthator-server \
--config "$scenario_dir/config.toml" \
--database $database_path \
--listen-host "127.0.0.1" \
--listen-port "8086" \
--static-assets "$project_root/assets" &
server_pid=$!
sleep 0.2
hurl \
--variable base_url="http://localhost:8086" \
--test --error-format long \
$scenario_dir/main.hurl
kill $server_pid
echo "End of scenario."

View file

@ -1,56 +0,0 @@
[instance]
base_uri = "http://localhost:8086"
name = "Example org"
logo_uri = "https://example.org/logo.png"
[[applications]]
slug = "demo_app"
name = "Demo app"
description = "A super application where you can do everything you want."
client_id = "00000001-0000-0000-0000-000000000001"
client_secret = "dummy_client_secret"
login_uri = "https://localhost:9876"
allowed_redirect_uris = [
"http://localhost:9090/callback",
"http://localhost:9876/callback"
]
visibility = "Internal"
authorize_flow = "Implicit"
[[applications]]
slug = "wiki"
name = "Wiki app"
description = "The knowledge base of the exemple org."
client_id = "f9de1885-448d-44bb-8c48-7e985486a8c6"
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
login_uri = "https://wiki.example.org/login"
allowed_redirect_uris = [
"https://wiki.example.org/oauth2/callback"
]
visibility = "Public"
authorize_flow = "Implicit"
[[applications]]
slug = "private_app"
name = "Demo app"
description = "Private app you should never discover"
client_id = "c8a08783-2342-4ce3-a3cb-9dc89b6bdf"
client_secret = "this_is_the_secret"
login_uri = "https://private-app.org"
allowed_redirect_uris = [
"http://localhost:9091/authorize",
]
visibility = "Private"
authorize_flow = "Implicit"
[[roles]]
slug = "basic"
name = "Basic"
description = "Basic user"
default = true
[[roles]]
slug = "admin"
name = "Administrator"
description = "Full power on organization instance"

View file

@ -1,9 +0,0 @@
password_hash="$(echo -n "root" | argon2 salt_06cGGWYDJCZ -e)"
echo $password_hash
SQL=$(cat <<EOF
INSERT INTO users
(id, handle, email, roles, status, password_hash, created_at)
VALUES
('$(uuid)', 'root', 'root@example.org', '[]', 'Active', '$password_hash', '2024-11-30T00:00:00Z');
EOF)
echo $SQL | sqlite3 $DB_PATH

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View file

@ -1,88 +0,0 @@
GET {{ base_url }}/api
HTTP 200
[Asserts]
jsonpath "$.software" == "Minauthator"
POST {{ base_url }}/login
[FormParams]
login: root
password: root
HTTP 303
[Captures]
user_jwt: cookie "minauthator_jwt"
[Asserts]
cookie "minauthator_jwt" exists
cookie "minauthator_jwt[Value]" contains "eyJ0"
cookie "minauthator_jwt[SameSite]" == "Lax"
GET {{ base_url }}/me
HTTP 200
Content-Type: text/html; charset=utf-8
[Asserts]
xpath "string(///h1)" == "Welcome root!"
POST {{ base_url }}/me/details-form
[MultipartFormData]
handle: root
email: root@johndoe.net
full_name: John Doe
website: https://johndoe.net
picture: file,john_doe_profile_pic.jpg; image/jpeg
HTTP 200
GET {{ base_url }}/me/authorizations
HTTP 200
[Asserts]
xpath "string(///h1)" == "Your authorizations"
xpath "string(///i)" == "You didn't authorized or accessed any applications for now."
# OAuth2 implicit flow (pre-granted app)
GET {{ base_url }}/authorize
[QueryStringParams]
client_id: 00000001-0000-0000-0000-000000000001
response_type: code
redirect_uri: http://localhost:9090/callback
state: Afk4kf6pbZkms78jM
scope: user_read_basic
HTTP 302
[Asserts]
header "Location" contains "http://localhost:9090/callback?code="
[Captures]
authorization_code: header "Location" regex "\\?code=(.*)&"
# OAuth2 get access token
POST {{ base_url }}/api/token
[BasicAuth]
00000001-0000-0000-0000-000000000001: dummy_client_secret
[FormParams]
code: {{ authorization_code }}
scope: user_read_basic
redirect_uri: http://localhost:9090/callback
grant_type: authorization_code
HTTP 200
Content-Type: application/json
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.access_token" matches "eyJ[[:alpha:]0-9].[[:alpha:]0-9].[[:alpha:]0-9]"
[Captures]
access_token: jsonpath "$.access_token"
# Get basic user info
GET {{ base_url }}/api/user
Authorization: JWT {{ access_token }}
HTTP 200
Content-Type: application/json
[Asserts]
jsonpath "$.handle" == "root"
jsonpath "$.email" == "root@johndoe.net"
GET {{ base_url }}/me/authorizations
HTTP 200
[Asserts]
xpath "string(///h1)" == "Your authorizations"
xpath "string(///main/ul/li)" contains "UserReadBasic"
GET {{ base_url }}/logout
HTTP 303
[Asserts]
cookie "minauthator_jwt" == ""

View file

@ -1,8 +0,0 @@
#!/usr/bin/sh
set -xeuo pipefail
./login.sh
./authorize.sh
./access_token_request.sh
./get_user_info.sh

View file

@ -1,5 +0,0 @@
INSERT INTO users
(id, handle, email, roles, status, password_hash, created_at)
VALUES
('30c134a7-d541-4ec7-9310-9c8e298077db', 'test', 'test@example.org', '[]', 'Active', '$argon2i$v=19$m=4096,t=3,p=1$V2laYjAwTlFHOUpiekRlVzRQUU0$33h8XwAWM3pKQM7Ksler0l7rMJfseTuWPJKrdX/cGyc', '2024-11-30T00:00:00Z');