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] [profile.dev]
codegen-backend = "cranelift" codegen-backend = "cranelift"
[workspace] [package]
members = [ name = "minauthator"
"lib/kernel", description = "Identity provider and OAuth2 server for an small-scale organization."
"lib/utils", version = "0.1.0"
"lib/http_server", edition = "2021"
"lib/admin_cli"
]
[workspace.dependencies] [dependencies]
# commons utils # commons utils
anyhow = "1.0" anyhow = "1.0"
fully_pub = "0.1" fully_pub = "0.1"
argon2 = "0.5"
strum = "0.26.3" strum = "0.26.3"
strum_macros = "0.26" strum_macros = "0.26"
uuid = { version = "1.8", features = ["serde", "v4"] } uuid = { version = "1.8", features = ["serde", "v4"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
base64 = "0.22.1"
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["std"] }
url = "2.5.3" url = "2.5.3"
argh = "0.1" # for CLI
# CLI
argh = "0.1"
# Async # Async
tokio = { version = "1.40.0", features = ["rt-multi-thread"] } tokio = { version = "1.40.0", features = ["rt-multi-thread"] }
@ -34,6 +34,7 @@ env_logger = "0.11"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_urlencoded = "0.7.1"
toml = "0.8" toml = "0.8"
chrono = { version = "0.4", features = ["serde"] } 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"] } sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "chrono", "uuid"] }
redis = { version = "0.27.3", default-features = false, features = ["acl"] } 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 # Auth utils
totp-rs = "5.6" 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://datatracker.ietf.org/doc/html/rfc6749
https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template 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" \ --cookie "tmp/.curl-cookies" \
-d client_id="a1785786-8be1-443c-9a6f-35feed703609" \ -d client_id="a1785786-8be1-443c-9a6f-35feed703609" \
-d response_type="code" \ -d response_type="code" \
-d redirect_uri="http://localhost:9090/callback" \ -d redirect_uri="http://localhost:9090/authorize" \
-d scope="user_read_basic" \ -d scope="user_read_basic" \
-d state="qxYAfk4kf6pbZkms78jM" -d state="qxYAfk4kf6pbZkms78jM"

View file

@ -1,12 +1,11 @@
export RUST_BACKTRACE := "1" export RUST_BACKTRACE := "1"
export RUST_LOG := "trace" export RUST_LOG := "trace"
export RUN_ARGS := "run --bin minauthator-server -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets"
watch-run: watch-run:
cargo-watch -x "$RUN_ARGS" cargo-watch -x 'run -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets'
run: run:
cargo $RUN_ARGS cargo run -- --database ./tmp/dbs/minauthator.db --config ./config.toml --static-assets ./assets
docker-run: docker-run:
docker run -p 3085:8080 -v ./tmp/docker/config:/etc/minauthator -v ./tmp/docker/db:/var/lib/minauthator minauthator 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 argh::FromArgs;
use anyhow::{Context, Result}; 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 log::info;
use crate::{get_app_context, server::{start_http_server, ServerConfig}, DEFAULT_ASSETS_PATH};
#[derive(Debug, FromArgs)] #[derive(Debug, FromArgs)]
/// Minauthator daemon /// Minauthator daemon
struct ServerCliFlags { struct ServerCliFlags {
@ -27,15 +27,14 @@ struct ServerCliFlags {
listen_port: u32 listen_port: u32
} }
/// handle CLI arguments to start HTTP server daemon /// handle CLI arguments to start process daemon
#[tokio::main] pub async fn start_server_cli() -> Result<()> {
pub async fn main() -> Result<()> {
info!("Starting minauth"); info!("Starting minauth");
let flags: ServerCliFlags = argh::from_env(); 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, config_path: flags.config,
database_path: flags.database database_path: flags.database
}).await.context("Getting kernel context")?; }).await.context("Getting app context")?;
start_http_server( start_http_server(
ServerConfig { ServerConfig {
assets_path: flags.static_assets.unwrap_or(DEFAULT_ASSETS_PATH.to_string()), 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 oauth2;
pub mod read_user; pub mod read_user;
pub mod openid; pub mod openid;

View file

@ -4,9 +4,10 @@ use fully_pub::fully_pub;
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use kernel::models::authorization::Authorization;
use crate::{ 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; const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
@ -42,7 +43,7 @@ pub async fn get_access_token(
) )
.bind(&form.code) .bind(&form.code)
.bind(&app_client_session.client_id) .bind(&app_client_session.client_id)
.fetch_one(&app_state.db.0) .fetch_one(&app_state.db)
.await; .await;
let authorization = match authorizations_res { let authorization = match authorizations_res {
Ok(val) => val, Ok(val) => val,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,15 @@
use axum_extra::extract::{cookie::{Cookie, SameSite}, CookieJar}; use axum_extra::extract::{cookie::{Cookie, SameSite}, CookieJar};
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use kernel::models::user::{User, UserStatus};
use log::info; use log::info;
use serde::Deserialize; 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 fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use time::Duration; 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( pub async fn login_form(
Extension(renderer): Extension<TemplateRenderer> 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") let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
.bind(&login.login) .bind(&login.login)
.bind(&login.login) .bind(&login.login)
.fetch_one(&app_state.db.0) .fetch_one(&app_state.db)
.await; .await;
let password_hash = match &user_res { 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") let _result = sqlx::query("UPDATE users SET last_login_at = $2 WHERE id = $1")
.bind(user.id.clone()) .bind(user.id.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)) .bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0) .execute(&app_state.db)
.await.unwrap(); .await.unwrap();
let jwt_max_age = Duration::days(15); let jwt_max_age = Duration::days(15);

View file

@ -1,7 +1,7 @@
use axum::response::{IntoResponse, Redirect}; use axum::response::{IntoResponse, Redirect};
use axum_extra::extract::CookieJar; 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( pub async fn perform_logout(
cookies: CookieJar cookies: CookieJar

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, ConnectOptions}; use sqlx::{sqlite::{SqliteConnectOptions, SqlitePoolOptions}, Pool, Sqlite, ConnectOptions};
use std::str::FromStr; use std::str::FromStr;
use crate::repositories::storage::Storage; pub async fn prepare_database(sqlite_db_path: &str) -> Result<Pool<Sqlite>> {
pub async fn prepare_database(sqlite_db_path: &str) -> Result<Storage> {
let conn_str = format!("sqlite:{}", sqlite_db_path); let conn_str = format!("sqlite:{}", sqlite_db_path);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
@ -16,6 +14,6 @@ pub async fn prepare_database(sqlite_db_path: &str) -> Result<Storage> {
.await .await
.context("could not connect to database_url")?; .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}, response::{Html, IntoResponse, Response},
Extension Extension
}; };
use utils::parse_basic_auth;
use crate::{ use crate::{
services::{app_session::AppClientSession, session::verify_token}, models::token_claims::AppUserTokenClaims, server::AppState, services::{app_session::AppClientSession, session::verify_token}, utils::parse_basic_auth
token_claims::AppUserTokenClaims,
AppState
}; };

View file

@ -1,5 +1,6 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension}; 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( pub async fn renderer_middleware(
State(app_state): State<AppState>, State(app_state): State<AppState>,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,9 @@
use axum::{http::StatusCode, response::{Html, IntoResponse}}; use axum::{http::StatusCode, response::{Html, IntoResponse}};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use kernel::models::config::Config;
use log::error; use log::error;
use minijinja::{context, Environment, Value}; use minijinja::{context, Environment, Value};
use utils::encode_base64_picture;
use crate::token_claims::UserTokenClaims; use crate::models::token_claims::UserTokenClaims;
#[derive(Debug, Clone)] #[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, app_auth,
renderer::renderer_middleware renderer::renderer_middleware
}, },
AppState, ServerConfig server::{AppState, ServerConfig}
}; };
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> { 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() let api_user_routes = Router::new()
.route("/api/user", get(api::read_user::read_user_basic)) .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)) .layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware));
.route("/api", get(api::index::get_index));
let well_known_routes = Router::new() let well_known_routes = Router::new()
.route("/.well-known/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration)); .route("/.well-known/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration));

View file

@ -1,22 +1,24 @@
pub mod controllers; use base64::{prelude::BASE64_STANDARD, Engine};
pub mod router;
pub mod services;
pub mod middlewares;
pub mod renderer;
pub mod token_claims;
use fully_pub::fully_pub; use fully_pub::fully_pub;
use anyhow::{Result, Context}; use anyhow::{Result, Context};
use kernel::{context::AppSecrets, models::config::Config, repositories::storage::Storage};
use log::info; 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::{ fn build_templating_env(config: &Config) -> Environment<'static> {
router::build_router, let mut env = Environment::new();
renderer::build_templating_env
};
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)] #[derive(Debug)]
#[fully_pub] #[fully_pub]
@ -32,7 +34,7 @@ pub struct ServerConfig {
pub struct AppState { pub struct AppState {
secrets: AppSecrets, secrets: AppSecrets,
config: Config, config: Config,
db: Storage, db: Pool<Sqlite>,
templating_env: Environment<'static> templating_env: Environment<'static>
} }
@ -40,7 +42,7 @@ pub async fn start_http_server(
server_config: ServerConfig, server_config: ServerConfig,
config: Config, config: Config,
secrets: AppSecrets, secrets: AppSecrets,
db_pool: Storage db_pool: Pool<Sqlite>
) -> Result<()> { ) -> Result<()> {
// build state // build state
let state = AppState { let state = AppState {

View file

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

View file

@ -1,7 +1,7 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Result, Context}; 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 { pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool {
app.allowed_redirect_uris 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 anyhow::Result;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey}; 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 { 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');