refactor: add renderer middleware + base of roles and authorizations

This commit is contained in:
Matthieu Bessat 2024-11-08 23:38:54 +01:00
parent 40b892391a
commit c277ab3bd9
30 changed files with 374 additions and 137 deletions

View file

@ -1,5 +1,7 @@
# Minauthator # Minauthator
Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749)
## Features ## Features
- [x] register - [x] register

View file

@ -7,3 +7,15 @@ slug = "demo_app"
name = "Demo app" name = "Demo app"
client_id = "a1785786-8be1-443c-9a6f-35feed703609" client_id = "a1785786-8be1-443c-9a6f-35feed703609"
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a" client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
[[roles]]
slug = "basic"
name = "Basic"
description = "Basic user"
default = true
[[roles]]
slug = "admin"
name = "Administrator"
description = "Full power on organization instance"

View file

@ -1,3 +1,5 @@
# OAuth2 spec # OAuth2 spec
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

View file

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
localhost FALSE / FALSE 1731761080 minauth_jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwZGZkOThlYy1mYjc3LTRjMWEtOTk5NS00Njg0Y2Y5NDM2NjYiLCJleHAiOjE3MzEyNDI2ODB9.Qnu8UiryN-NZIMk2-YorCuqY5g0ZJwRdszeBe_Y5S3E

View file

@ -0,0 +1,10 @@
#!/usr/bin/sh
curl -v http://localhost:8085/authorize \
-G \
--cookie ".curl-cookies" \
-d client_id="a1785786-8be1-443c-9a6f-35feed703609" \
-d response_type="code" \
-d redirect_uri="https://localhost:9090/authorize" \
-d scope="read_basics" \
-d state="qxYAfk4kf6pbZkms78jM"

View file

@ -0,0 +1,6 @@
#!/usr/bin/sh
curl -v http://localhost:8085/login \
--cookie-jar ".curl-cookies" \
-d login="test" \
-d password="test"

View file

@ -0,0 +1,6 @@
#!/usr/bin/sh
curl -v http://localhost:8085/register \
-d email="test@example.org" \
-d handle="test" \
-d password="test"

View file

@ -6,6 +6,7 @@ CREATE TABLE users (
email TEXT UNIQUE, email TEXT UNIQUE,
website TEXT, website TEXT,
picture BLOB, picture BLOB,
roles TEXT NOT NULL, -- json array of user roles
status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled', status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled',
password_hash TEXT, password_hash TEXT,
@ -13,3 +14,15 @@ CREATE TABLE users (
last_login_at DATETIME, last_login_at DATETIME,
created_at DATETIME NOT NULL created_at DATETIME NOT NULL
); );
DROP TABLE IF EXISTS authorizations;
CREATE TABLE authorizations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
scopes TEXT, -- json array of app scope (permissions)
last_used_at DATETIME,
created_at DATETIME NOT NULL
);

View file

@ -1,22 +1,81 @@
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}, Extension}; use axum::{extract::{Query, State}, http::StatusCode, response::{Html, IntoResponse}, Extension, Form};
use fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use serde::Deserialize;
use crate::{server::AppState, services::session::TokenClaims}; use crate::{models::authorization::Authorization, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
#[derive(Deserialize)]
#[fully_pub]
/// query params described in [RFC6759 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1)
struct AuthorizeQueryParams {
response_type: String,
client_id: String,
scope: String,
redirect_uri: String,
/// An opaque value used by the client to maintain state between the request and callback
state: String,
}
pub async fn authorize_form( pub async fn authorize_form(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<TokenClaims>,
Extension(renderer): Extension<TemplateRenderer>,
query_params: Query<AuthorizeQueryParams>
) -> impl IntoResponse { ) -> impl IntoResponse {
// 1. Verify the app details
let app_res = app_state.config.applications
.iter()
.find(|a| a.client_id == query_params.client_id);
// 1. Check if the app is already authorized if app_res.is_none() {
// 2. Query the app details return (
StatusCode::BAD_REQUEST,
Html( Html("Invalid client_id query params, app not found.")
app_state.templating_env.get_template("pages/authorize.html").unwrap() ).into_response();
.render(context!())
.unwrap()
)
} }
// 2. Check if the app is already authorized
let authorizations_res = sqlx::query_as::<_, Authorization>("SELECT * FROM authorizations WHERE user_id = $1 AND
app_id = $2")
.bind(&token_claims.sub)
.bind(&query_params.client_id)
.fetch_one(&app_state.db)
.await
.expect("To get authorizations");
dbg!(authorizations_res);
// 3. Verify scopes
// 4. Show form that POST to authorize
renderer
.render(
"pages/authorize",
context!()
)
.into_response()
}
#[derive(Debug, Deserialize)]
#[fully_pub]
struct AuthorizeForm {
/// client_id
client_id: String,
scopes: Vec<String>
}
pub async fn perform_authorize(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Form(authorize_form): Form<AuthorizeForm>
) -> impl IntoResponse {
// Save authorization in DB
// 4.1. Create an authorization code
// 4.2. Redirect to the app with a token
(StatusCode::FOUND, Html("Redirecting…"))
}

View file

@ -1,17 +1,16 @@
use axum::{extract::State, response::{Html, IntoResponse}}; use axum::{response::IntoResponse, Extension};
use axum_macros::debug_handler; use axum_macros::debug_handler;
use minijinja::context; use minijinja::context;
use crate::server::AppState; use crate::renderer::TemplateRenderer;
#[debug_handler] #[debug_handler]
pub async fn home( pub async fn home(
State(app_state): State<AppState> Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse { ) -> impl IntoResponse {
Html( renderer.render(
app_state.templating_env.get_template("pages/home.html").unwrap() "pages/home",
.render(context!()) context!()
.unwrap()
) )
} }

View file

@ -1,23 +1,20 @@
use chrono::{Duration, SecondsFormat, Utc}; use chrono::{Duration, SecondsFormat, Utc};
use log::info; use log::info;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form}; use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Extension, Form};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use crate::{ use crate::{
models::user::{User, UserStatus}, models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token}
server::AppState,
services::{password::verify_password_hash, session::create_token}
}; };
pub async fn login_form( pub async fn login_form(
State(app_state): State<AppState> Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse { ) -> impl IntoResponse {
Html( renderer.render(
app_state.templating_env.get_template("pages/login.html").unwrap() "pages/login",
.render(context!()) context!()
.unwrap()
) )
} }
@ -33,6 +30,7 @@ const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7k
pub async fn perform_login( pub async fn perform_login(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Form(login): Form<LoginForm> Form(login): Form<LoginForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
// get user from db // get user from db
@ -50,15 +48,14 @@ pub async fn perform_login(
Err(_e) => DUMMY_PASSWORD_HASH.into() Err(_e) => DUMMY_PASSWORD_HASH.into()
}; };
let templ = app_state.templating_env.get_template("pages/login.html").unwrap();
if verify_password_hash(password_hash, login.password).is_err() { if verify_password_hash(password_hash, login.password).is_err() {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Html( renderer.render(
templ.render(context!( "pages/login",
context!(
error => Some("Invalid login or password.".to_string()) error => Some("Invalid login or password.".to_string())
)).unwrap() )
) )
).into_response(); ).into_response();
} }
@ -67,10 +64,11 @@ pub async fn perform_login(
if user.status == UserStatus::Disabled { if user.status == UserStatus::Disabled {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Html( renderer.render(
templ.render(context!( "pages/login",
context!(
error => Some("This account is disabled.".to_string()) error => Some("This account is disabled.".to_string())
)).unwrap() )
) )
).into_response(); ).into_response();
} }
@ -87,13 +85,16 @@ pub async fn perform_login(
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting // TODO: handle keep_session boolean from form and specify cookie max age only if this setting
// is true // is true
let cookie_max_age = Duration::days(7).num_seconds(); let cookie_max_age = Duration::days(7).num_seconds();
// enforce SameSite=Lax to avoid CSRF
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}"); let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap()); headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap()); headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap());
(StatusCode::FOUND, headers, Html( (
templ.render(context!()).unwrap() StatusCode::FOUND,
)).into_response() headers,
Html("")
).into_response()
} }

View file

@ -1,13 +1,13 @@
use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension}; use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use base64::prelude::{Engine, BASE64_STANDARD};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use minijinja::context; use minijinja::context;
use crate::{models::user::User, server::AppState, services::session::TokenClaims}; use crate::{models::user::User, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
pub async fn me_page( pub async fn me_page(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -17,19 +17,18 @@ pub async fn me_page(
.await .await
.expect("To get user from claim"); .expect("To get user from claim");
Html( renderer.render(
app_state.templating_env.get_template("pages/me/index.html").unwrap() "pages/me/index",
.render(context!( context!(
user => user_res, user => user_res
user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x)) )
))
.unwrap()
) )
} }
pub async fn me_update_details_form( pub async fn me_update_details_form(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims> Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -39,12 +38,11 @@ pub async fn me_update_details_form(
.await .await
.expect("To get user from claim"); .expect("To get user from claim");
Html( renderer.render(
app_state.templating_env.get_template("pages/me/details-form.html").unwrap() "pages/me/details-form",
.render(context!( context!(
user => user_res user => user_res
)) )
.unwrap()
) )
} }
@ -64,10 +62,11 @@ struct UserDetailsUpdateForm {
pub async fn me_perform_update_details( pub async fn me_perform_update_details(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<TokenClaims>, Extension(token_claims): Extension<TokenClaims>,
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm> TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
let template = app_state.templating_env.get_template("pages/me/details-form.html").unwrap(); let template_path = "pages/me/details-form";
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1") let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1")
.bind(&token_claims.sub) .bind(&token_claims.sub)
@ -79,8 +78,6 @@ pub async fn me_perform_update_details(
.execute(&app_state.db) .execute(&app_state.db)
.await; .await;
dbg!(&update_res);
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) .fetch_one(&app_state.db)
@ -89,28 +86,23 @@ pub async fn me_perform_update_details(
match update_res { match update_res {
Ok(_) => { Ok(_) => {
( renderer.render(
StatusCode::OK, template_path,
Html( context!(
template.render(context!(
success => true, success => true,
user => user_res user => user_res
))
.unwrap()
) )
).into_response() )
}, },
Err(err) => { Err(err) => {
dbg!(&err); dbg!(&err);
( renderer.render(
StatusCode::BAD_REQUEST, template_path,
Html( context!(
template.render(context!(
error => Some("Cannot update user details".to_string()), error => Some("Cannot update user details".to_string()),
user => user_res user => user_res
)).unwrap()
) )
).into_response() )
} }
} }

View file

@ -1,11 +1,13 @@
use axum::{extract::State, response::{Html, IntoResponse}, Form}; use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form};
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize}; use log::{error, info, warn};
use serde::Deserialize;
use minijinja::context; use minijinja::context;
use fully_pub::fully_pub; use fully_pub::fully_pub;
use sqlx::types::Json;
use uuid::Uuid; use uuid::Uuid;
use crate::{models::user::{User, UserStatus}, server::AppState, services::password::get_password_hash}; use crate::{models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::password::get_password_hash};
pub async fn register_form( pub async fn register_form(
State(app_state): State<AppState> State(app_state): State<AppState>
@ -28,22 +30,9 @@ struct RegisterForm {
pub async fn perform_register( pub async fn perform_register(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Form(register): Form<RegisterForm> Form(register): Form<RegisterForm>
) -> impl IntoResponse { ) -> impl IntoResponse {
let templ = app_state.templating_env.get_template("pages/register.html").unwrap();
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
.bind(&register.handle)
.bind(&register.email)
.fetch_one(&app_state.db)
.await;
if user_res.is_ok() {
// user already exists
return Html(
templ.render(context!(
success => true
)).unwrap()
);
}
let password_hash = Some( let password_hash = Some(
get_password_hash(register.password) get_password_hash(register.password)
@ -57,29 +46,55 @@ pub async fn perform_register(
picture: None, picture: None,
password_hash, password_hash,
activation_token: None,
status: UserStatus::Active, status: UserStatus::Active,
roles: Json(Vec::new()), // take the default role in the config
activation_token: None,
created_at: Utc::now(), created_at: Utc::now(),
website: None, website: None,
last_login_at: None last_login_at: None
}; };
// save in DB // save in DB
let _result = sqlx::query("INSERT INTO users (id, handle, email, status, password_hash, created_at) VALUES ($1, $2, $3, $4, $5, $6)") let res = sqlx::query("
INSERT INTO users
(id, handle, email, status, roles, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
")
.bind(user.id) .bind(user.id)
.bind(user.handle) .bind(user.handle)
.bind(user.email) .bind(user.email)
.bind(user.status.to_string()) .bind(user.status.to_string())
.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) .execute(&app_state.db)
.await.unwrap(); .await;
match res {
Err(err) => {
let err_code = err.as_database_error().unwrap().code().unwrap();
if err_code == "2067" {
warn!("Cannot register user because email or handle is not unique. Failing silently.");
} else {
error!("Cannot register user: {}", err);
return renderer.render_with_status(
StatusCode::INTERNAL_SERVER_ERROR,
"pages/register",
context!(
error => true
)
)
}
},
Ok(_v) => {
info!("Registered user successfully");
}
};
Html( renderer.render_with_status(
templ StatusCode::OK,
.render(context!( "pages/register",
context!(
success => true success => true
)) )
.unwrap()
) )
} }

View file

@ -7,6 +7,7 @@ pub mod cli;
pub mod utils; pub mod utils;
pub mod services; pub mod services;
pub mod middlewares; pub mod middlewares;
pub mod renderer;
use std::{env, fs}; use std::{env, fs};
use anyhow::{Result, Context, anyhow}; use anyhow::{Result, Context, anyhow};

View file

@ -1,28 +1,49 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response}; use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::{Html, IntoResponse, Response}, Extension};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use crate::{server::AppState, services::session::verify_token}; use crate::{server::AppState, services::session::{TokenClaims, verify_token}};
/// add optional auth to the extension data
pub async fn auth_middleware( pub async fn auth_middleware(
State(app_state): State<AppState>, State(app_state): State<AppState>,
cookies: CookieJar, cookies: CookieJar,
mut req: Request, mut req: Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, impl IntoResponse> {
let jwt = match cookies.get("minauth_jwt") { let jwt = match cookies.get("minauth_jwt") {
Some(cookie) => cookie.value(), Some(cookie) => cookie.value(),
None => { None => {
// return Err((StatusCode::UNAUTHORIZED, Html("Did not found header"))); // no auth found, auth may be optional
return Err(StatusCode::UNAUTHORIZED); return Ok(next.run(req).await)
} }
}; };
let token_claims = match verify_token(&app_state.secrets, &jwt) { let token_claims = match verify_token(&app_state.secrets, &jwt) {
Ok(val) => val, Ok(val) => val,
Err(_e) => { Err(_e) => {
return Err(StatusCode::UNAUTHORIZED); return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided is invalid."))
);
} }
}; };
req.extensions_mut().insert(token_claims); req.extensions_mut().insert(token_claims);
Ok(next.run(req).await) Ok(next.run(req).await)
} }
/// require auth
pub async fn enforce_auth_middleware(
token_claims_ext: Option<Extension<TokenClaims>>,
req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
match token_claims_ext {
Some(_val) => (),
None => {
// auth is required
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: auth is required on this page."))
);
}
};
Ok(next.run(req).await)
}

View file

@ -1 +1,2 @@
pub mod auth; pub mod auth;
pub mod renderer;

View file

@ -0,0 +1,17 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension};
use crate::{renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
pub async fn renderer_middleware(
State(app_state): State<AppState>,
token_claims_ext: Option<Extension<TokenClaims>>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let renderer_instance = TemplateRenderer {
env: app_state.templating_env,
token_claims: token_claims_ext.map(|x| x.0)
};
req.extensions_mut().insert(renderer_instance);
Ok(next.run(req).await)
}

View file

@ -1,9 +1,11 @@
use fully_pub::fully_pub; use fully_pub::fully_pub;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::Json;
#[derive(Serialize, Deserialize)] #[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
enum Permissions { #[derive(strum_macros::Display)]
enum AuthorizationScope {
ReadBasics ReadBasics
} }
@ -13,8 +15,9 @@ struct Authorization {
/// uuid /// uuid
id: String, id: String,
user_id: String, user_id: String,
app_id: String, /// app_id
permissions: Vec<Permissions>, client_id: String,
scopes: Json<Vec<AuthorizationScope>>,
last_used_at: Option<DateTime<Utc>>, last_used_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc> created_at: DateTime<Utc>

View file

@ -5,6 +5,8 @@ use fully_pub::fully_pub;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
const fn _default_true() -> bool { true }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[fully_pub] #[fully_pub]
/// Instance branding/customization config /// Instance branding/customization config
@ -22,18 +24,31 @@ struct Application {
client_secret: String client_secret: String
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[fully_pub]
struct Role {
slug: String,
name: String,
description: Option<String>,
#[serde(default = "_default_true")]
default: bool
}
// todo: Role hierarchy https://en.wikipedia.org/wiki/Role_hierarchy
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[fully_pub] #[fully_pub]
/// Configuration of this minauthator instance /// Configuration of this minauthator instance
struct Config { struct Config {
/// configure current autotasker instance /// configure current autotasker instance
instance: InstanceConfig, instance: InstanceConfig,
applications: Vec<Application> applications: Vec<Application>,
roles: Vec<Role>
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[fully_pub] #[fully_pub]
pub struct AppSecrets { struct AppSecrets {
jwt_secret: String jwt_secret: String
} }

View file

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

View file

@ -1,6 +1,7 @@
use fully_pub::fully_pub; use fully_pub::fully_pub;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::Json;
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(strum_macros::Display)] #[derive(strum_macros::Display)]
@ -22,9 +23,9 @@ struct User {
picture: Option<Vec<u8>>, // embeded blob to store profile pic picture: Option<Vec<u8>>, // embeded blob to store profile pic
password_hash: Option<String>, // argon2 password hash password_hash: Option<String>, // argon2 password hash
status: UserStatus, status: UserStatus,
roles: Json<Vec<String>>,
activation_token: Option<String>, activation_token: Option<String>,
last_login_at: Option<DateTime<Utc>>, last_login_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc> created_at: DateTime<Utc>
} }

45
src/renderer.rs Normal file
View file

@ -0,0 +1,45 @@
use axum::{http::StatusCode, response::{Html, IntoResponse}};
use fully_pub::fully_pub;
use log::error;
use minijinja::{context, Environment, Value};
use crate::services::session::TokenClaims;
#[derive(Clone)]
#[fully_pub]
struct TemplateRenderer {
env: Environment<'static>,
token_claims: Option<TokenClaims>
}
impl TemplateRenderer {
/// Helper method to output HTML as response
pub(crate) fn render(&self, name: &str, ctx: Value) -> impl IntoResponse {
match self
.env
.get_template(&format!("{name}.html"))
.and_then(|tmpl| tmpl.render(context! {
token_claims => self.token_claims,
..ctx
}))
{
Ok(content) => Html(content).into_response(),
Err(err) => {
dbg!(err);
error!("FATAL: Failed to render template {}", name);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
}
pub(crate) fn render_with_status(&self, status: StatusCode, name: &str, ctx: Value) -> impl IntoResponse {
let mut res = self.render(name, ctx).into_response();
if res.status().is_server_error() {
return res
}
*res.status_mut() = status;
return res;
}
}

View file

@ -1,7 +1,7 @@
use axum::{middleware, routing::{get, post}, Router}; use axum::{middleware, routing::{get, post}, Router};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}}; use crate::{controllers::ui, middlewares::{auth::{auth_middleware, enforce_auth_middleware}, renderer::renderer_middleware}, 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> {
let public_routes = Router::new() let public_routes = Router::new()
@ -17,11 +17,13 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
.route("/me/details-form", post(ui::me::me_perform_update_details)) .route("/me/details-form", post(ui::me::me_perform_update_details))
.route("/logout", get(ui::logout::perform_logout)) .route("/logout", get(ui::logout::perform_logout))
.route("/authorize", get(ui::authorize::authorize_form)) .route("/authorize", get(ui::authorize::authorize_form))
.layer(middleware::from_fn_with_state(app_state, auth_middleware)); .layer(middleware::from_fn_with_state(app_state.clone(), enforce_auth_middleware));
Router::new() Router::new()
.merge(public_routes) .merge(public_routes)
.merge(user_routes) .merge(user_routes)
.layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware))
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
.nest_service( .nest_service(
"/assets", "/assets",
ServeDir::new(server_config.assets_path.clone()) ServeDir::new(server_config.assets_path.clone())

View file

@ -1,3 +1,4 @@
use base64::{prelude::BASE64_STANDARD, Engine};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use anyhow::{Result, Context}; use anyhow::{Result, Context};
use log::info; use log::info;
@ -13,6 +14,9 @@ fn build_templating_env(config: &Config) -> Environment<'static> {
env.add_global("gl", context! { env.add_global("gl", context! {
instance => config.instance instance => config.instance
}); });
env.add_function("encode_b64str", |bin_val: Vec<u8>| {
BASE64_STANDARD.encode(bin_val)
});
env env
} }

View file

@ -6,7 +6,7 @@ use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Val
use crate::models::{config::AppSecrets, user::User}; use crate::models::{config::AppSecrets, user::User};
#[derive(Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub] #[fully_pub]
struct TokenClaims { struct TokenClaims {
/// user id /// user id

View file

@ -1,11 +1,7 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top"> <footer class="py-3 mt-4 border-top fixed-bottom">
<div class="container-fluid"> <div class="container-fluid d-flex flex-wrap justify-content-between align-items-center">
<p class="col-md-4 mb-0 text-muted">Minauth</p> <p class="col-md-4 mb-0 text-muted">Minauth</p>
<a href="/" class="col-md-4 d-flex align-items-center justify-content-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
</a>
<ul class="nav col-md-4 justify-content-end"> <ul class="nav col-md-4 justify-content-end">
<li class="nav-item"><a href="/" class="nav-link px-2 text-muted">Home</a></li> <li class="nav-item"><a href="/" class="nav-link px-2 text-muted">Home</a></li>
<li class="nav-item"><a href="/about" class="nav-link px-2 text-muted">About</a></li> <li class="nav-item"><a href="/about" class="nav-link px-2 text-muted">About</a></li>

View file

@ -5,23 +5,25 @@
<div class="collapse navbar-collapse"> <div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a> <a class="nav-link active" aria-current="page" href="/">Home</a>
</li> </li>
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
{% if token_claims is none %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/login">Login</a> <a class="nav-link" href="/login">Login</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/register">Register</a> <a class="nav-link" href="/register">Register</a>
</li> </li>
{% else %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/me">Me</a> <a class="nav-link" href="/me">Me</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/logout">Logout</a> <a class="nav-link" href="/logout">Logout</a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -7,8 +7,14 @@
</div> </div>
{% endif %} {% endif %}
<form id="authorize-form" method="post"> <form id="authorize-form" method="post">
<input id="keep_session" type="checkbox" class="form-check-input"> <h1>Do you authorize this app?</h1>
<label class="form-check-label" for="keep_session">Check me out</label> <ul>
<li>App name: </li>
<li>Permisions: read basics</li>
</ul>
<input type="hidden" name="client_id" value="" />
<input type="hidden" name="scope" value="" />
<input type="hidden" name="state" value="" />
<button type="submit" class="btn btn-primary">Authorize</button> <button type="submit" class="btn btn-primary">Authorize</button>
</form> </form>

View file

@ -5,8 +5,8 @@
<a href="/me/details-form">Update details</a> <a href="/me/details-form">Update details</a>
<p> <p>
{% if user_picture %} {% if user.picture %}
<img src="data:image/*;base64,{{ user_picture }}" style="width: 150px; height: 150px; object-fit: contain"> <img src="data:image/*;base64,{{ encode_b64str(user.picture) }}" style="width: 150px; height: 150px; object-fit: contain">
{% endif %} {% endif %}
<ul> <ul>
<li> <li>

View file

@ -24,7 +24,7 @@
class="form-control" class="form-control"
/> />
</div> </div>
<div> <div class="mb-3">
<label for="email">Email</label> <label for="email">Email</label>
<input <input
id="email" name="email" type="email" id="email" name="email" type="email"
@ -32,7 +32,7 @@
class="form-control" class="form-control"
/> />
</div> </div>
<div> <div class="mb-3">
<label for="password">Password</label> <label for="password">Password</label>
<input <input
id="password" name="password" type="password" id="password" name="password" type="password"