From c277ab3bd9602b282e46a56bc3efdd1691e27047 Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Fri, 8 Nov 2024 23:38:54 +0100 Subject: [PATCH] refactor: add renderer middleware + base of roles and authorizations --- README.md | 2 + config.toml | 12 +++++ docs/draft.md | 2 + http_integration_tests/.curl-cookies | 5 ++ http_integration_tests/authorize.sh | 10 ++++ http_integration_tests/login.sh | 6 +++ http_integration_tests/register.sh | 6 +++ migrations/all.sql | 13 +++++ src/controllers/ui/authorize.rs | 81 ++++++++++++++++++++++++---- src/controllers/ui/home.rs | 13 +++-- src/controllers/ui/login.rs | 43 +++++++-------- src/controllers/ui/me.rs | 62 ++++++++++----------- src/controllers/ui/register.rs | 67 ++++++++++++++--------- src/main.rs | 1 + src/middlewares/auth.rs | 33 +++++++++--- src/middlewares/mod.rs | 1 + src/middlewares/renderer.rs | 17 ++++++ src/models/authorization.rs | 11 ++-- src/models/config.rs | 19 ++++++- src/models/mod.rs | 1 + src/models/user.rs | 3 +- src/renderer.rs | 45 ++++++++++++++++ src/router.rs | 6 ++- src/server.rs | 4 ++ src/services/session.rs | 2 +- src/templates/components/footer.html | 22 ++++---- src/templates/components/header.html | 6 ++- src/templates/pages/authorize.html | 10 +++- src/templates/pages/me/index.html | 4 +- src/templates/pages/register.html | 4 +- 30 files changed, 374 insertions(+), 137 deletions(-) create mode 100644 http_integration_tests/.curl-cookies create mode 100755 http_integration_tests/authorize.sh create mode 100755 http_integration_tests/login.sh create mode 100755 http_integration_tests/register.sh create mode 100644 src/middlewares/renderer.rs create mode 100644 src/renderer.rs diff --git a/README.md b/README.md index cbd1a4d..3b81684 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Minauthator +Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749) + ## Features - [x] register diff --git a/config.toml b/config.toml index a371acc..dce54a2 100644 --- a/config.toml +++ b/config.toml @@ -7,3 +7,15 @@ slug = "demo_app" name = "Demo app" client_id = "a1785786-8be1-443c-9a6f-35feed703609" 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" + diff --git a/docs/draft.md b/docs/draft.md index 9539fa5..aa65361 100644 --- a/docs/draft.md +++ b/docs/draft.md @@ -1,3 +1,5 @@ # OAuth2 spec https://datatracker.ietf.org/doc/html/rfc6749 + +https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template diff --git a/http_integration_tests/.curl-cookies b/http_integration_tests/.curl-cookies new file mode 100644 index 0000000..5a2fba3 --- /dev/null +++ b/http_integration_tests/.curl-cookies @@ -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 diff --git a/http_integration_tests/authorize.sh b/http_integration_tests/authorize.sh new file mode 100755 index 0000000..a4a6f3d --- /dev/null +++ b/http_integration_tests/authorize.sh @@ -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" diff --git a/http_integration_tests/login.sh b/http_integration_tests/login.sh new file mode 100755 index 0000000..5e5ed12 --- /dev/null +++ b/http_integration_tests/login.sh @@ -0,0 +1,6 @@ +#!/usr/bin/sh + +curl -v http://localhost:8085/login \ + --cookie-jar ".curl-cookies" \ + -d login="test" \ + -d password="test" diff --git a/http_integration_tests/register.sh b/http_integration_tests/register.sh new file mode 100755 index 0000000..c2a5f36 --- /dev/null +++ b/http_integration_tests/register.sh @@ -0,0 +1,6 @@ +#!/usr/bin/sh + +curl -v http://localhost:8085/register \ + -d email="test@example.org" \ + -d handle="test" \ + -d password="test" diff --git a/migrations/all.sql b/migrations/all.sql index 22d4e83..f024839 100644 --- a/migrations/all.sql +++ b/migrations/all.sql @@ -6,6 +6,7 @@ CREATE TABLE users ( email TEXT UNIQUE, website TEXT, picture BLOB, + roles TEXT NOT NULL, -- json array of user roles status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled', password_hash TEXT, @@ -13,3 +14,15 @@ CREATE TABLE users ( last_login_at DATETIME, 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 +); + diff --git a/src/controllers/ui/authorize.rs b/src/controllers/ui/authorize.rs index 7454730..f68a594 100644 --- a/src/controllers/ui/authorize.rs +++ b/src/controllers/ui/authorize.rs @@ -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 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( State(app_state): State, - Extension(token_claims): Extension + Extension(token_claims): Extension, + Extension(renderer): Extension, + query_params: Query ) -> 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 - // 2. Query the app details + if app_res.is_none() { + return ( + StatusCode::BAD_REQUEST, + Html("Invalid client_id query params, app not found.") + ).into_response(); + } - Html( - app_state.templating_env.get_template("pages/authorize.html").unwrap() - .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 +} + + +pub async fn perform_authorize( + State(app_state): State, + Extension(renderer): Extension, + Form(authorize_form): Form +) -> 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…")) +} diff --git a/src/controllers/ui/home.rs b/src/controllers/ui/home.rs index 5d5c656..f44ce35 100644 --- a/src/controllers/ui/home.rs +++ b/src/controllers/ui/home.rs @@ -1,17 +1,16 @@ -use axum::{extract::State, response::{Html, IntoResponse}}; +use axum::{response::IntoResponse, Extension}; use axum_macros::debug_handler; use minijinja::context; -use crate::server::AppState; +use crate::renderer::TemplateRenderer; #[debug_handler] pub async fn home( - State(app_state): State + Extension(renderer): Extension ) -> impl IntoResponse { - Html( - app_state.templating_env.get_template("pages/home.html").unwrap() - .render(context!()) - .unwrap() + renderer.render( + "pages/home", + context!() ) } diff --git a/src/controllers/ui/login.rs b/src/controllers/ui/login.rs index 8700267..8d79a72 100644 --- a/src/controllers/ui/login.rs +++ b/src/controllers/ui/login.rs @@ -1,23 +1,20 @@ use chrono::{Duration, SecondsFormat, Utc}; use log::info; -use serde::{Deserialize, Serialize}; -use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form}; +use serde::Deserialize; +use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Extension, Form}; use fully_pub::fully_pub; use minijinja::context; use crate::{ - models::user::{User, UserStatus}, - server::AppState, - services::{password::verify_password_hash, session::create_token} + models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token} }; pub async fn login_form( - State(app_state): State + Extension(renderer): Extension ) -> impl IntoResponse { - Html( - app_state.templating_env.get_template("pages/login.html").unwrap() - .render(context!()) - .unwrap() + renderer.render( + "pages/login", + context!() ) } @@ -33,6 +30,7 @@ const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7k pub async fn perform_login( State(app_state): State, + Extension(renderer): Extension, Form(login): Form ) -> impl IntoResponse { // get user from db @@ -50,15 +48,14 @@ pub async fn perform_login( 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() { return ( StatusCode::BAD_REQUEST, - Html( - templ.render(context!( + renderer.render( + "pages/login", + context!( error => Some("Invalid login or password.".to_string()) - )).unwrap() + ) ) ).into_response(); } @@ -67,10 +64,11 @@ pub async fn perform_login( if user.status == UserStatus::Disabled { return ( StatusCode::BAD_REQUEST, - Html( - templ.render(context!( + renderer.render( + "pages/login", + context!( error => Some("This account is disabled.".to_string()) - )).unwrap() + ) ) ).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 // is true 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 mut headers = HeaderMap::new(); headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap()); headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap()); - (StatusCode::FOUND, headers, Html( - templ.render(context!()).unwrap() - )).into_response() + ( + StatusCode::FOUND, + headers, + Html("") + ).into_response() } diff --git a/src/controllers/ui/me.rs b/src/controllers/ui/me.rs index b649e9b..d420ae4 100644 --- a/src/controllers/ui/me.rs +++ b/src/controllers/ui/me.rs @@ -1,13 +1,13 @@ use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; -use base64::prelude::{Engine, BASE64_STANDARD}; use fully_pub::fully_pub; 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( State(app_state): State, + Extension(renderer): Extension, Extension(token_claims): Extension ) -> impl IntoResponse { @@ -17,19 +17,18 @@ pub async fn me_page( .await .expect("To get user from claim"); - Html( - app_state.templating_env.get_template("pages/me/index.html").unwrap() - .render(context!( - user => user_res, - user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x)) - )) - .unwrap() + renderer.render( + "pages/me/index", + context!( + user => user_res + ) ) } pub async fn me_update_details_form( State(app_state): State, + Extension(renderer): Extension, Extension(token_claims): Extension ) -> impl IntoResponse { @@ -39,12 +38,11 @@ pub async fn me_update_details_form( .await .expect("To get user from claim"); - Html( - app_state.templating_env.get_template("pages/me/details-form.html").unwrap() - .render(context!( - user => user_res - )) - .unwrap() + renderer.render( + "pages/me/details-form", + context!( + user => user_res + ) ) } @@ -64,10 +62,11 @@ struct UserDetailsUpdateForm { pub async fn me_perform_update_details( State(app_state): State, + Extension(renderer): Extension, Extension(token_claims): Extension, TypedMultipart(details_update): TypedMultipart ) -> 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") .bind(&token_claims.sub) @@ -79,8 +78,6 @@ pub async fn me_perform_update_details( .execute(&app_state.db) .await; - dbg!(&update_res); - let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(&token_claims.sub) .fetch_one(&app_state.db) @@ -89,28 +86,23 @@ pub async fn me_perform_update_details( match update_res { Ok(_) => { - ( - StatusCode::OK, - Html( - template.render(context!( - success => true, - user => user_res - )) - .unwrap() + renderer.render( + template_path, + context!( + success => true, + user => user_res ) - ).into_response() + ) }, Err(err) => { dbg!(&err); - ( - StatusCode::BAD_REQUEST, - Html( - template.render(context!( - error => Some("Cannot update user details".to_string()), - user => user_res - )).unwrap() + renderer.render( + template_path, + context!( + error => Some("Cannot update user details".to_string()), + user => user_res ) - ).into_response() + ) } } diff --git a/src/controllers/ui/register.rs b/src/controllers/ui/register.rs index 434cb9f..28ba1db 100644 --- a/src/controllers/ui/register.rs +++ b/src/controllers/ui/register.rs @@ -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 serde::{Deserialize, Serialize}; +use log::{error, info, warn}; +use serde::Deserialize; use minijinja::context; use fully_pub::fully_pub; +use sqlx::types::Json; 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( State(app_state): State @@ -28,22 +30,9 @@ struct RegisterForm { pub async fn perform_register( State(app_state): State, + Extension(renderer): Extension, Form(register): Form ) -> 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(®ister.handle) - .bind(®ister.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( get_password_hash(register.password) @@ -57,29 +46,55 @@ pub async fn perform_register( picture: None, password_hash, - activation_token: None, status: UserStatus::Active, + roles: Json(Vec::new()), // take the default role in the config + activation_token: None, created_at: Utc::now(), website: None, last_login_at: None }; // 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.handle) .bind(user.email) .bind(user.status.to_string()) + .bind(user.roles) .bind(user.password_hash) .bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true)) .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( - templ - .render(context!( - success => true - )) - .unwrap() + renderer.render_with_status( + StatusCode::OK, + "pages/register", + context!( + success => true + ) ) } diff --git a/src/main.rs b/src/main.rs index 53c9c00..11cd4ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ pub mod cli; pub mod utils; pub mod services; pub mod middlewares; +pub mod renderer; use std::{env, fs}; use anyhow::{Result, Context, anyhow}; diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 269ff07..eabab9c 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -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 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( State(app_state): State, cookies: CookieJar, mut req: Request, next: Next, -) -> Result { +) -> Result { let jwt = match cookies.get("minauth_jwt") { Some(cookie) => cookie.value(), None => { - // return Err((StatusCode::UNAUTHORIZED, Html("Did not found header"))); - return Err(StatusCode::UNAUTHORIZED); + // no auth found, auth may be optional + return Ok(next.run(req).await) } }; let token_claims = match verify_token(&app_state.secrets, &jwt) { Ok(val) => val, Err(_e) => { - return Err(StatusCode::UNAUTHORIZED); + return Err( + (StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided is invalid.")) + ); } }; req.extensions_mut().insert(token_claims); Ok(next.run(req).await) } + +/// require auth +pub async fn enforce_auth_middleware( + token_claims_ext: Option>, + req: Request, + next: Next, +) -> Result { + 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) +} diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 0e4a05d..25e5df7 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod renderer; diff --git a/src/middlewares/renderer.rs b/src/middlewares/renderer.rs new file mode 100644 index 0000000..68da808 --- /dev/null +++ b/src/middlewares/renderer.rs @@ -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, + token_claims_ext: Option>, + mut req: Request, + next: Next, +) -> Result { + 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) +} diff --git a/src/models/authorization.rs b/src/models/authorization.rs index 13c409b..d89eb94 100644 --- a/src/models/authorization.rs +++ b/src/models/authorization.rs @@ -1,9 +1,11 @@ use fully_pub::fully_pub; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use sqlx::types::Json; -#[derive(Serialize, Deserialize)] -enum Permissions { +#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(strum_macros::Display)] +enum AuthorizationScope { ReadBasics } @@ -13,8 +15,9 @@ struct Authorization { /// uuid id: String, user_id: String, - app_id: String, - permissions: Vec, + /// app_id + client_id: String, + scopes: Json>, last_used_at: Option>, created_at: DateTime diff --git a/src/models/config.rs b/src/models/config.rs index 8261adb..183d598 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -5,6 +5,8 @@ use fully_pub::fully_pub; use serde::{Deserialize, Serialize}; use uuid::Uuid; +const fn _default_true() -> bool { true } + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[fully_pub] /// Instance branding/customization config @@ -22,18 +24,31 @@ struct Application { client_secret: String } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[fully_pub] +struct Role { + slug: String, + name: String, + description: Option, + #[serde(default = "_default_true")] + default: bool +} + +// todo: Role hierarchy https://en.wikipedia.org/wiki/Role_hierarchy + #[derive(Debug, Clone, Serialize, Deserialize)] #[fully_pub] /// Configuration of this minauthator instance struct Config { /// configure current autotasker instance instance: InstanceConfig, - applications: Vec + applications: Vec, + roles: Vec } #[derive(Debug, Clone)] #[fully_pub] -pub struct AppSecrets { +struct AppSecrets { jwt_secret: String } diff --git a/src/models/mod.rs b/src/models/mod.rs index 2c3f277..37a7310 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,2 +1,3 @@ pub mod config; pub mod user; +pub mod authorization; diff --git a/src/models/user.rs b/src/models/user.rs index bfcbde6..0a707bd 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,6 +1,7 @@ use fully_pub::fully_pub; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use sqlx::types::Json; #[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(strum_macros::Display)] @@ -22,9 +23,9 @@ struct User { picture: Option>, // embeded blob to store profile pic password_hash: Option, // argon2 password hash status: UserStatus, + roles: Json>, activation_token: Option, last_login_at: Option>, created_at: DateTime } - diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..7cbf412 --- /dev/null +++ b/src/renderer.rs @@ -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 +} + +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; + } +} + diff --git a/src/router.rs b/src/router.rs index cb3be72..6997e32 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,7 +1,7 @@ use axum::{middleware, routing::{get, post}, Router}; 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 { 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("/logout", get(ui::logout::perform_logout)) .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() .merge(public_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( "/assets", ServeDir::new(server_config.assets_path.clone()) diff --git a/src/server.rs b/src/server.rs index 3c5a7f9..6833988 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use base64::{prelude::BASE64_STANDARD, Engine}; use fully_pub::fully_pub; use anyhow::{Result, Context}; use log::info; @@ -13,6 +14,9 @@ fn build_templating_env(config: &Config) -> Environment<'static> { env.add_global("gl", context! { instance => config.instance }); + env.add_function("encode_b64str", |bin_val: Vec| { + BASE64_STANDARD.encode(bin_val) + }); env } diff --git a/src/services/session.rs b/src/services/session.rs index b5ee29c..3941385 100644 --- a/src/services/session.rs +++ b/src/services/session.rs @@ -6,7 +6,7 @@ use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Val use crate::models::{config::AppSecrets, user::User}; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct TokenClaims { /// user id diff --git a/src/templates/components/footer.html b/src/templates/components/footer.html index 1569200..537239e 100644 --- a/src/templates/components/footer.html +++ b/src/templates/components/footer.html @@ -1,15 +1,11 @@ - diff --git a/src/templates/components/header.html b/src/templates/components/header.html index 794483c..23c450c 100644 --- a/src/templates/components/header.html +++ b/src/templates/components/header.html @@ -5,23 +5,25 @@ diff --git a/src/templates/pages/authorize.html b/src/templates/pages/authorize.html index 95f1129..7003d68 100644 --- a/src/templates/pages/authorize.html +++ b/src/templates/pages/authorize.html @@ -7,8 +7,14 @@ {% endif %}
- - +

Do you authorize this app?

+
    +
  • App name:
  • +
  • Permisions: read basics
  • +
+ + +
diff --git a/src/templates/pages/me/index.html b/src/templates/pages/me/index.html index 97c5d4f..3ab2ee7 100644 --- a/src/templates/pages/me/index.html +++ b/src/templates/pages/me/index.html @@ -5,8 +5,8 @@ Update details

-{% if user_picture %} - +{% if user.picture %} + {% endif %}

  • diff --git a/src/templates/pages/register.html b/src/templates/pages/register.html index b9f2881..8701dc3 100644 --- a/src/templates/pages/register.html +++ b/src/templates/pages/register.html @@ -24,7 +24,7 @@ class="form-control" /> -
    +
    -
    +