refactor: structure of an hexagonal architecture

Created a kernel crate to store models and future action implementations.
Will be useful to create admin cli.
This commit is contained in:
Matthieu Bessat 2024-11-28 12:47:00 +01:00
parent 69af48bb62
commit 3713cc2443
87 changed files with 834 additions and 474 deletions

View file

@ -0,0 +1,51 @@
[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"

3
lib/http_server/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
minijinja_embed::embed_templates!("src/templates");
}

View file

@ -0,0 +1,14 @@
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

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

View file

@ -0,0 +1,91 @@
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form, Json};
use chrono::{Duration, Utc};
use fully_pub::fully_pub;
use log::error;
use serde::{Deserialize, Serialize};
use kernel::models::authorization::Authorization;
use crate::{
services::{app_session::AppClientSession, session::create_token}, token_claims::AppUserTokenClaims, AppState
};
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
#[derive(Serialize, Deserialize)]
#[fully_pub]
struct AccessTokenRequestParams {
grant_type: String,
code: String,
redirect_uri: String,
}
#[derive(Serialize, Deserialize)]
#[fully_pub]
struct AccessTokenResponse {
access_token: String,
token_type: String,
expires_in: u64
}
// implement Client -> Auth Server request for RFC6749 Authorization Code Grant
pub async fn get_access_token(
State(app_state): State<AppState>,
Extension(app_client_session): Extension<AppClientSession>,
Form(form): Form<AccessTokenRequestParams>
) -> impl IntoResponse {
// 1. This handler require client authentification
// login the client with client_id and client_secret
// 2. Get authorization from DB and validate code
let authorizations_res = sqlx::query_as::<_, Authorization>(
"SELECT * FROM authorizations WHERE code = $1 AND client_id = $2"
)
.bind(&form.code)
.bind(&app_client_session.client_id)
.fetch_one(&app_state.db.0)
.await;
let authorization = match authorizations_res {
Ok(val) => val,
Err(sqlx::Error::RowNotFound) => {
return (
StatusCode::BAD_REQUEST,
Json("Invalid authorization_code.")
).into_response();
},
Err(err) => {
error!("Could not fetch authorization. {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("Internal server error")
).into_response();
}
};
// 2.2. Validate that the authorization code is not expired
let is_code_valid = authorization.last_used_at
.map_or(false, |ts| {
Utc::now().signed_duration_since(ts) < Duration::seconds(AUTHORIZATION_CODE_TTL_SECONDS)
});
if !is_code_valid {
return (
StatusCode::BAD_REQUEST,
Json("Authorization code has expired.")
).into_response();
}
// 3. Generate JWT for oauth2 client user session
let jwt = create_token(
&app_state.secrets,
AppUserTokenClaims::new(
&app_client_session.client_id,
&authorization.user_id,
authorization.scopes.to_vec()
)
);
// 4. return JWT
let access_token_res = AccessTokenResponse {
access_token: jwt,
token_type: "jwt".to_string(),
expires_in: 3600
};
Json(access_token_res).into_response()
}

View file

@ -0,0 +1 @@
pub mod access_token;

View file

@ -0,0 +1 @@
pub mod well_known;

View file

@ -0,0 +1,34 @@
use axum::{extract::State, response::IntoResponse, Json};
use fully_pub::fully_pub;
use kernel::models::authorization::AuthorizationScope;
use serde::Serialize;
use strum::IntoEnumIterator;
use crate::AppState;
#[derive(Serialize)]
#[fully_pub]
struct WellKnownOpenIdConfiguration {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
userinfo_endpoint: String,
scopes_supported: Vec<String>,
response_types_supported: Vec<String>,
token_endpoint_auth_methods_supported: Vec<String>
}
pub async fn get_well_known_openid_configuration(
State(app_state): State<AppState>,
) -> impl IntoResponse {
let base_url = app_state.config.instance.base_uri;
Json(WellKnownOpenIdConfiguration {
issuer: base_url.clone(),
authorization_endpoint: format!("{}/authorize", base_url),
token_endpoint: format!("{}/api/token", base_url),
userinfo_endpoint: format!("{}/api/user", base_url),
scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
response_types_supported: vec!["code".into()],
token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()],
})
}

View file

@ -0,0 +1,38 @@
use axum::{extract::State, response::IntoResponse, Extension, Json};
use fully_pub::fully_pub;
use serde::Serialize;
use crate::{token_claims::AppUserTokenClaims, AppState};
use kernel::models::user::User;
#[derive(Serialize)]
#[fully_pub]
struct ReadUserBasicExtract {
id: String,
handle: String,
full_name: Option<String>,
email: Option<String>,
website: Option<String>,
roles: Vec<String>
}
pub async fn read_user_basic(
State(app_state): State<AppState>,
Extension(token_claims): Extension<AppUserTokenClaims>,
) -> impl IntoResponse {
// 1. This handler require app user authentification (JWT)
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.user_id)
.fetch_one(&app_state.db.0)
.await
.expect("To get user from claim");
let output = ReadUserBasicExtract {
id: user_res.id,
handle: user_res.handle,
full_name: user_res.full_name,
email: user_res.email,
website: user_res.website,
roles: user_res.roles.to_vec()
};
Json(output).into_response()
}

View file

@ -0,0 +1,2 @@
pub mod api;
pub mod ui;

View file

@ -0,0 +1,32 @@
use axum::{extract::State, response::IntoResponse, Extension};
use minijinja::context;
use kernel::models::{config::AppVisibility, config::Application};
use crate::{
renderer::TemplateRenderer,
AppState
};
pub async fn list_apps(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
) -> impl IntoResponse {
// implement app discovery
// for now, we just list all apps in the organization
let apps: Vec<&Application> = app_state.config
.applications
.iter()
.filter(|a|
a.visibility == AppVisibility::Public ||
a.visibility == AppVisibility::Internal
).collect();
renderer.render(
"pages/apps",
context!(
apps => apps
)
)
}

View file

@ -0,0 +1,233 @@
use axum::{extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Extension, Form};
use chrono::{SecondsFormat, Utc};
use fully_pub::fully_pub;
use log::{debug, error, info};
use minijinja::context;
use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
use kernel::{
models::{authorization::Authorization, config::AppAuthorizeFlow}
};
use utils::get_random_alphanumerical;
use crate::{
renderer::TemplateRenderer, services::oauth2::{parse_scope, verify_redirect_uri}, token_claims::UserTokenClaims, AppState
};
#[derive(Debug, Serialize, Deserialize)]
#[fully_pub]
/// query params described in [RFC6749 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1)
struct AuthorizationParams {
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,
}
fn redirect_to_client(
authorization_code: &str,
authorization_params: &AuthorizationParams
) -> impl IntoResponse {
let target_url = format!("{}?code={}&state={}",
authorization_params.redirect_uri,
authorization_code,
authorization_params.state,
);
debug!("Redirecting to {}", target_url);
let mut headers = HeaderMap::new();
headers.insert("Location", HeaderValue::from_str(&target_url).unwrap());
(
StatusCode::FOUND,
headers,
Html("Redirecting to client…")
).into_response()
}
/// The authorization endpoint is used to interact with the resource owner and obtain an authorization grant.
/// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
pub async fn authorize_form(
State(app_state): State<AppState>,
Extension(token_claims): Extension<UserTokenClaims>,
Extension(renderer): Extension<TemplateRenderer>,
query_params: Query<AuthorizationParams>
) -> impl IntoResponse {
let Query(authorization_params) = query_params;
// 1. Verify the app details
let app = match app_state.config.applications
.iter()
.find(|a| a.client_id == authorization_params.client_id) {
Some(app) => app,
None => {
return (
StatusCode::BAD_REQUEST,
Html("Invalid client_id query params, app not found.")
).into_response();
}
};
// 1.1. Verify that the app redirect_uri is authorized
if !verify_redirect_uri(app, &authorization_params.redirect_uri) {
return (
StatusCode::BAD_REQUEST,
Html("Unauthorized redirect_uri.")
).into_response();
}
// 1.3. Parse and validate redirect_uri.
// Note: for now, we only support HTTP(s) redirect URLs.
let parsed_redirect_uri: Url = match Url::parse(&authorization_params.redirect_uri) {
Ok(url) => url,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Html("Invalid redirect URL. Could not parse as HTTP(S) URL.")
).into_response();
}
};
// 1.4. Parse and validate scopes
let scopes = match parse_scope(&authorization_params.scope) {
Ok(v) => v,
Err(_err) => {
return (
StatusCode::BAD_REQUEST,
Html("Invalid scopes. Scopes must be space-delimited and snake_case.")
).into_response();
}
};
// 2. Check if the app is already authorized
let authorizations_res = sqlx::query_as::<_, Authorization>(
"SELECT * FROM authorizations WHERE user_id = $1 AND client_id = $2 AND scopes = $3"
)
.bind(&token_claims.sub)
.bind(&authorization_params.client_id)
.bind(sqlx::types::Json(&scopes))
.fetch_one(&app_state.db.0)
.await;
match authorizations_res {
Ok(existing_authorization) => {
info!("Reusing existing authorization: {}", &existing_authorization.id);
// Create new auth code
let authorization_code = get_random_alphanumerical(32);
// Update last used timestamp for this authorization
let _result = sqlx::query("UPDATE authorizations SET code = $2, last_used_at = $3 WHERE id = $1")
.bind(existing_authorization.id)
.bind(authorization_code.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.await.unwrap();
// Authorization already given, just redirect to the app
return redirect_to_client(
&authorization_code,
&authorization_params
).into_response()
},
Err(sqlx::Error::RowNotFound) => {
debug!("Authorization not found.");
},
Err(err) => {
error!("Cannot get existing authorization. {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("Internal server error: Failed to verify conditions.")
).into_response();
}
}
// 3. Check for implicit/explicit flow
if app.authorize_flow == AppAuthorizeFlow::Implicit {
debug!("Performing Implicit authorization flow.");
// Authorization already given, just redirect to the app
return perform_authorize(
State(app_state),
Extension(token_claims),
Form(authorization_params)
).await.into_response()
}
// 4. Show form that POST to authorize
debug!("Performing explicit authorization flow.");
renderer
.render(
"pages/authorize",
context!(
app => app,
authorization_params => authorization_params,
redirect_uri_host => parsed_redirect_uri.host_str()
)
)
.into_response()
}
pub async fn perform_authorize(
State(app_state): State<AppState>,
Extension(token_claims): Extension<UserTokenClaims>,
Form(authorize_form): Form<AuthorizationParams>
) -> impl IntoResponse {
// 1. Get the app details
let app = match app_state.config.applications
.iter()
.find(|a| a.client_id == authorize_form.client_id) {
Some(app) => app,
None => {
return (
StatusCode::BAD_REQUEST,
Html("Invalid client_id, app not found.")
).into_response();
}
};
// 1.2. Parse and validate scope to use in DB
let scopes = match parse_scope(&authorize_form.scope) {
Ok(v) => v,
Err(_err) => {
return (
StatusCode::BAD_REQUEST,
Html("Invalid scopes.")
).into_response();
}
};
// 2. Create an authorization code
let authorization_code = get_random_alphanumerical(32);
let authorization = Authorization {
id: Uuid::new_v4().to_string(),
user_id: token_claims.sub,
client_id: app.client_id.clone(),
scopes: sqlx::types::Json(scopes),
code: authorization_code.clone(),
last_used_at: Some(Utc::now()),
created_at: Utc::now(),
};
// 3. Save authorization in DB with state
let res = sqlx::query("
INSERT INTO authorizations
(id, user_id, client_id, scopes, code, last_used_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
")
.bind(authorization.id.clone())
.bind(authorization.user_id)
.bind(authorization.client_id)
.bind(authorization.scopes)
.bind(authorization.code)
.bind(authorization.last_used_at.map(|x| x.to_rfc3339_opts(SecondsFormat::Millis, true)))
.bind(authorization.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.await;
if let Err(err) = res {
error!("Failed to save authorization in DB. {}", err);
return (StatusCode::INTERNAL_SERVER_ERROR, Html("Internal server error: Failed to process authorization form.")).into_response();
}
info!("Created authorization {}", &authorization.id);
// 4. Redirect to the app with the authorization code and state
redirect_to_client(&authorization_code, &authorize_form).into_response()
}

View file

@ -0,0 +1,16 @@
use axum::{response::IntoResponse, Extension};
use axum_macros::debug_handler;
use minijinja::context;
use crate::renderer::TemplateRenderer;
#[debug_handler]
pub async fn home(
Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse {
renderer.render(
"pages/home",
context!()
)
}

View file

@ -0,0 +1,113 @@
use axum_extra::extract::{cookie::{Cookie, SameSite}, CookieJar};
use chrono::{SecondsFormat, Utc};
use kernel::models::user::{User, UserStatus};
use log::info;
use serde::Deserialize;
use axum::{extract::{Query, State}, http::StatusCode, response::{IntoResponse, Redirect}, Extension, Form};
use fully_pub::fully_pub;
use minijinja::context;
use time::Duration;
use utils::verify_password_hash;
use crate::{renderer::TemplateRenderer, services::session::create_token, token_claims::UserTokenClaims, AppState, WEB_GUI_JWT_COOKIE_NAME};
pub async fn login_form(
Extension(renderer): Extension<TemplateRenderer>
) -> impl IntoResponse {
renderer.render(
"pages/login",
context!()
)
}
#[derive(Debug, Deserialize)]
#[fully_pub]
struct LoginForm {
/// handle or email or user_id
login: String,
password: String
}
const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7kUIXjWM4g$6XqwuHt/+xl0d5J4BYKuIbg2acBp6udxMCnmJ6QfceY";
#[derive(Deserialize)]
#[fully_pub]
struct LoginQueryParams {
redirect_to: Option<String>
}
pub async fn perform_login(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
cookies: CookieJar,
Query(query_params): Query<LoginQueryParams>,
Form(login): Form<LoginForm>
) -> impl IntoResponse {
// get user from db
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
.bind(&login.login)
.bind(&login.login)
.fetch_one(&app_state.db.0)
.await;
let password_hash = match &user_res {
Ok(u) => u
.password_hash
.clone()
.unwrap_or(DUMMY_PASSWORD_HASH.into()),
Err(_e) => DUMMY_PASSWORD_HASH.into()
};
if verify_password_hash(password_hash, login.password).is_err() {
return (
StatusCode::BAD_REQUEST,
renderer.render(
"pages/login",
context!(
error => Some("Invalid login or password.".to_string())
)
)
).into_response();
}
let user = user_res.expect("Expected User to be found.");
if user.status == UserStatus::Disabled {
return (
StatusCode::BAD_REQUEST,
renderer.render(
"pages/login",
context!(
error => Some("This account is disabled.".to_string())
)
)
).into_response();
}
info!("User {:?} {:?} logged in", &user.handle, &user.email);
let _result = sqlx::query("UPDATE users SET last_login_at = $2 WHERE id = $1")
.bind(user.id.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.await.unwrap();
let jwt_max_age = Duration::days(15);
let claims = UserTokenClaims::new(&user.id, jwt_max_age);
let jwt = create_token(&app_state.secrets, claims);
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting
// is true
let cookie_max_age = jwt_max_age - Duration::seconds(32);
// enforce SameSite=Lax to avoid CSRF
let jwt_cookie = Cookie::build((WEB_GUI_JWT_COOKIE_NAME, jwt))
.same_site(SameSite::Lax)
.max_age(cookie_max_age);
// TODO: check redirection for arbitrary URL, enforce relative path
let redirection_target = query_params.redirect_to.unwrap_or("/me".to_string());
(
cookies.add(jwt_cookie),
Redirect::to(&redirection_target)
).into_response()
}

View file

@ -0,0 +1,14 @@
use axum::response::{IntoResponse, Redirect};
use axum_extra::extract::CookieJar;
use crate::WEB_GUI_JWT_COOKIE_NAME;
pub async fn perform_logout(
cookies: CookieJar
) -> impl IntoResponse {
(
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
Redirect::to("/")
)
}

View file

@ -0,0 +1,115 @@
use axum::{body::Bytes, extract::State, response::IntoResponse, Extension};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use fully_pub::fully_pub;
use log::error;
use minijinja::context;
use crate::{
token_claims::UserTokenClaims,
renderer::TemplateRenderer,
AppState
};
use kernel::models::user::User;
pub async fn me_page(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<UserTokenClaims>
) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.await
.expect("To get user from claim");
renderer.render(
"pages/me/index",
context!(
user => user_res
)
)
}
pub async fn me_update_details_form(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<UserTokenClaims>
) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.await
.expect("To get user from claim");
renderer.render(
"pages/me/details-form",
context!(
user => user_res
)
)
}
#[fully_pub]
#[derive(Debug, TryFromMultipart)]
struct UserDetailsUpdateForm {
handle: String,
email: String,
full_name: String,
website: String,
#[form_data(limit = "5MiB")]
picture: FieldData<Bytes>
}
pub async fn me_perform_update_details(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<UserTokenClaims>,
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
) -> impl IntoResponse {
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)
.bind(details_update.handle)
.bind(details_update.email)
.bind(details_update.full_name)
.bind(details_update.website)
.bind(details_update.picture.contents.to_vec())
.execute(&app_state.db.0)
.await;
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.await
.expect("To get user from claim");
match update_res {
Ok(_) => {
renderer.render(
template_path,
context!(
success => true,
user => user_res
)
)
},
Err(err) => {
error!("Cannot update user details. {}", err);
renderer.render(
template_path,
context!(
error => Some("Cannot update user details.".to_string()),
user => user_res
)
)
}
}
}

View file

@ -0,0 +1,8 @@
pub mod home;
pub mod authorize;
pub mod login;
pub mod register;
pub mod me;
pub mod logout;
pub mod user_panel;
pub mod apps;

View file

@ -0,0 +1,104 @@
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form};
use chrono::{SecondsFormat, Utc};
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::{renderer::TemplateRenderer, AppState};
use kernel::models::user::{User, UserStatus};
use utils::get_password_hash;
pub async fn register_form(
State(app_state): State<AppState>
) -> impl IntoResponse {
Html(
app_state.templating_env.get_template("pages/register.html").unwrap()
.render(context!())
.unwrap()
)
}
#[derive(Debug, Deserialize)]
#[fully_pub]
struct RegisterForm {
handle: String,
email: String,
password: String,
}
pub async fn perform_register(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Form(register): Form<RegisterForm>
) -> impl IntoResponse {
let password_hash = Some(
get_password_hash(register.password)
.expect("To process password").1
);
let user = User {
id: Uuid::new_v4().to_string(),
email: Some(register.email),
handle: register.handle,
full_name: None,
picture: None,
password_hash,
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 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.0)
.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");
}
};
renderer.render_with_status(
StatusCode::OK,
"pages/register",
context!(
success => true
)
)
}

View file

@ -0,0 +1,61 @@
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse, Redirect}, Extension, Form};
use fully_pub::fully_pub;
use log::error;
use minijinja::context;
use serde::Deserialize;
use kernel::models::authorization::Authorization;
use crate::{renderer::TemplateRenderer, token_claims::UserTokenClaims, AppState};
pub async fn get_authorizations(
State(app_state): State<AppState>,
Extension(renderer): Extension<TemplateRenderer>,
Extension(token_claims): Extension<UserTokenClaims>,
) -> impl IntoResponse {
let user_authorizations = sqlx::query_as::<_, Authorization>("SELECT * FROM authorizations WHERE user_id = $1")
.bind(&token_claims.sub)
.fetch_all(&app_state.db.0)
.await
.expect("To get user authorization with user_id from claim");
renderer.render(
"pages/user_panel/authorizations",
context!(
user_authorizations => user_authorizations
)
)
}
#[derive(Debug, Deserialize)]
#[fully_pub]
struct RevokeAuthorizationForm {
authorization_id: String
}
pub async fn revoke_authorization(
State(app_state): State<AppState>,
Form(form): Form<RevokeAuthorizationForm>
) -> impl IntoResponse {
let delete_res = sqlx::query("DELETE FROM authorizations WHERE id = $1")
.bind(&form.authorization_id)
.execute(&app_state.db.0)
.await;
match delete_res {
Ok(_) => {},
Err(sqlx::Error::RowNotFound) => {
return (
StatusCode::BAD_REQUEST,
Html("Could not find authorization.")
).into_response();
},
Err(err) => {
error!("Failed to delete authorization, {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Html("Failed to delete authorization.")
).into_response();
}
}
Redirect::to("/me/authorizations").into_response()
}

View file

@ -0,0 +1 @@
pub mod authorizations;

View file

@ -0,0 +1,67 @@
pub mod controllers;
pub mod router;
pub mod services;
pub mod middlewares;
pub mod renderer;
pub mod token_claims;
use fully_pub::fully_pub;
use anyhow::{Result, Context};
use kernel::{context::AppSecrets, models::config::Config, repositories::storage::Storage};
use log::info;
use minijinja::Environment;
use crate::{
router::build_router,
renderer::build_templating_env
};
pub const WEB_GUI_JWT_COOKIE_NAME: &str = "minauthator_jwt";
#[derive(Debug)]
#[fully_pub]
/// HTTP server arguments
pub struct ServerConfig {
listen_host: String,
listen_port: u32,
assets_path: String
}
#[derive(Debug, Clone)]
#[fully_pub]
pub struct AppState {
secrets: AppSecrets,
config: Config,
db: Storage,
templating_env: Environment<'static>
}
pub async fn start_http_server(
server_config: ServerConfig,
config: Config,
secrets: AppSecrets,
db_pool: Storage
) -> Result<()> {
// build state
let state = AppState {
templating_env: build_templating_env(&config),
config,
secrets,
db: db_pool
};
// build routes
let services = build_router(
&server_config,
state.clone()
)
.with_state(state);
let listen_addr = format!("{}:{}", server_config.listen_host, server_config.listen_port);
info!("Starting web server on http://{}", &listen_addr);
let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap();
axum::serve(listener, services).await.context("Axum serve")?;
Ok(())
}

View file

@ -0,0 +1,49 @@
use argh::FromArgs;
use anyhow::{Context, Result};
use http_server::{start_http_server, ServerConfig};
use kernel::{consts::DEFAULT_ASSETS_PATH, context::{get_kernel_context, StartKernelConfig}};
use log::info;
#[derive(Debug, FromArgs)]
/// Minauthator daemon
struct ServerCliFlags {
/// path to YAML config file to use to configure this instance
#[argh(option)]
config: Option<String>,
/// path to the Sqlite3 DB file to use
#[argh(option)]
database: Option<String>,
/// path to the static assets dir
#[argh(option)]
static_assets: Option<String>,
/// HTTP listen host
#[argh(option, default="String::from(\"localhost\")")]
listen_host: String,
/// HTTP listen port
#[argh(option, default="8085")]
listen_port: u32
}
/// handle CLI arguments to start HTTP server daemon
#[tokio::main]
pub async fn main() -> Result<()> {
info!("Starting minauth");
let flags: ServerCliFlags = argh::from_env();
let (config, secrets, db_pool) = get_kernel_context(StartKernelConfig {
config_path: flags.config,
database_path: flags.database
}).await.context("Getting kernel context")?;
start_http_server(
ServerConfig {
assets_path: flags.static_assets.unwrap_or(DEFAULT_ASSETS_PATH.to_string()),
listen_host: flags.listen_host,
listen_port: flags.listen_port
},
config,
secrets,
db_pool
).await
}

View file

@ -0,0 +1,115 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Response},
Extension
};
use utils::parse_basic_auth;
use crate::{
services::{app_session::AppClientSession, session::verify_token},
token_claims::AppUserTokenClaims,
AppState
};
/// add optional auth to the extension data
pub async fn basic_auth_middleware(
State(app_state): State<AppState>,
headers: HeaderMap,
mut req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
let authorization_val = match headers.get("Authorization") {
Some(header_val) => header_val.to_str().expect("Header val to be string"),
None => {
// no auth found, auth may be optional
return Ok(next.run(req).await)
}
};
// check with config
let (login, password) = match parse_basic_auth(authorization_val) {
Ok(v) => v,
Err(_e) => {
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: invalid http basic header."))
);
}
};
let app = match app_state.config.applications
.iter()
.find(|a| a.client_id == login)
{
Some(app) => app,
None => {
return Err((
StatusCode::UNAUTHORIZED,
Html("Unauthorized: Invalid username or password.")
))
}
};
if app.client_secret != password {
return Err((
StatusCode::UNAUTHORIZED,
Html("Unauthorized: Invalid username or password.")
))
}
req.extensions_mut().insert(AppClientSession {
client_id: login
});
Ok(next.run(req).await)
}
/// require auth
pub async fn enforce_basic_auth_middleware(
app_client_session_ext: Option<Extension<AppClientSession>>,
req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
match app_client_session_ext {
Some(_val) => (),
None => {
// auth is required
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: application basic HTTP auth is required on this page."))
);
}
};
Ok(next.run(req).await)
}
/// require App-User auth
pub async fn enforce_jwt_auth_middleware(
State(app_state): State<AppState>,
headers: HeaderMap,
mut req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
let authorization_val = match headers.get("Authorization") {
Some(header_val) => header_val.to_str().expect("Header val to be string"),
None => {
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: JWT must be provided."))
);
}
};
let jwt = match authorization_val.split(" ").nth(1) {
Some(val) => val,
None => {
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: malformed Authorization header."))
);
}
};
let token_claims: AppUserTokenClaims = match verify_token(&app_state.secrets, jwt) {
Ok(val) => val,
Err(_e) => {
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid."))
);
}
};
req.extensions_mut().insert(token_claims);
Ok(next.run(req).await)
}

View file

@ -0,0 +1,3 @@
pub mod user_auth;
pub mod app_auth;
pub mod renderer;

View file

@ -0,0 +1,16 @@
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension};
use crate::{renderer::TemplateRenderer, token_claims::UserTokenClaims, AppState};
pub async fn renderer_middleware(
State(app_state): State<AppState>,
token_claims_ext: Option<Extension<UserTokenClaims>>,
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

@ -0,0 +1,69 @@
use axum::{
extract::{OriginalUri, Request, State},
middleware::Next,
response::{IntoResponse, Redirect, Response},
Extension
};
use axum_extra::extract::CookieJar;
use crate::{
services::session::verify_token,
token_claims::UserTokenClaims,
AppState, WEB_GUI_JWT_COOKIE_NAME
};
/// add optional auth to the extension data
pub async fn auth_middleware(
State(app_state): State<AppState>,
OriginalUri(original_uri): OriginalUri,
cookies: CookieJar,
mut req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
let jwt = match cookies.get(WEB_GUI_JWT_COOKIE_NAME) {
Some(cookie) => cookie.value(),
None => {
// no auth found, auth may be optional
return Ok(next.run(req).await)
}
};
let token_claims: UserTokenClaims = match verify_token(&app_state.secrets, jwt) {
Ok(val) => val,
Err(_e) => {
// UserWebGUI: delete invalid JWT cookie
return Err(
(
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
Redirect::to(&original_uri.to_string())
)
);
}
};
req.extensions_mut().insert(token_claims);
Ok(next.run(req).await)
}
/// require auth
pub async fn enforce_auth_middleware(
OriginalUri(original_uri): OriginalUri,
token_claims_ext: Option<Extension<UserTokenClaims>>,
req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
match token_claims_ext {
Some(_val) => (),
None => {
// auth is required
// redirect to login UI
let target_url = format!(
"/login?{}",
serde_urlencoded::to_string(&[
("redirect_to", original_uri.to_string())
]).expect("To encode URI")
);
return Err(Redirect::to(&target_url));
}
};
Ok(next.run(req).await)
}

View file

@ -0,0 +1,58 @@
use axum::{http::StatusCode, response::{Html, IntoResponse}};
use fully_pub::fully_pub;
use kernel::models::config::Config;
use log::error;
use minijinja::{context, Environment, Value};
use utils::encode_base64_picture;
use crate::token_claims::UserTokenClaims;
#[derive(Debug, Clone)]
#[fully_pub]
struct TemplateRenderer {
env: Environment<'static>,
token_claims: Option<UserTokenClaims>
}
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;
res
}
}
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

@ -0,0 +1,62 @@
use axum::{middleware, routing::{get, post}, Router};
use tower_http::services::ServeDir;
use crate::{
controllers::ui,
controllers::api,
middlewares::{
user_auth,
app_auth,
renderer::renderer_middleware
},
AppState, ServerConfig
};
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
let public_routes = Router::new()
.route("/", get(ui::home::home))
.route("/register", get(ui::register::register_form))
.route("/register", post(ui::register::perform_register))
.route("/login", get(ui::login::login_form))
.route("/login", post(ui::login::perform_login))
.layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware))
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware));
let user_routes = Router::new()
.route("/logout", get(ui::logout::perform_logout))
.route("/authorize", get(ui::authorize::authorize_form))
.route("/authorize", post(ui::authorize::perform_authorize))
.route("/apps", get(ui::apps::list_apps))
.route("/me", get(ui::me::me_page))
.route("/me/details-form", get(ui::me::me_update_details_form))
.route("/me/details-form", post(ui::me::me_perform_update_details))
.route("/me/authorizations", get(ui::user_panel::authorizations::get_authorizations))
.route("/me/authorizations/revoke", post(ui::user_panel::authorizations::revoke_authorization))
.layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware))
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::enforce_auth_middleware))
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware));
let api_app_routes = Router::new()
.route("/api/token", post(api::oauth2::access_token::get_access_token))
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_basic_auth_middleware))
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::basic_auth_middleware));
let api_user_routes = Router::new()
.route("/api/user", get(api::read_user::read_user_basic))
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware))
.route("/api", get(api::index::get_index));
let well_known_routes = Router::new()
.route("/.well-known/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration));
Router::new()
.merge(public_routes)
.merge(user_routes)
.merge(api_app_routes)
.merge(api_user_routes)
.merge(well_known_routes)
.nest_service(
"/assets",
ServeDir::new(server_config.assets_path.clone())
)
}

View file

@ -0,0 +1,12 @@
use fully_pub::fully_pub;
use serde::{Deserialize, Serialize};
/// represent a general app session (from http basic auth)
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct AppClientSession {
client_id: String
}

View file

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

View file

@ -0,0 +1,20 @@
use std::str::FromStr;
use anyhow::{Result, Context};
use kernel::models::{authorization::AuthorizationScope, config::Application};
pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool {
app.allowed_redirect_uris
.iter()
.any(|uri| *uri == input_redirect_uri)
}
pub fn parse_scope(scope_str: &str) -> Result<Vec<AuthorizationScope>> {
let mut scopes: Vec<AuthorizationScope> = vec![];
for part in scope_str.split(' ') {
scopes.push(
AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?
)
}
Ok(scopes)
}

View file

@ -0,0 +1,25 @@
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
use kernel::context::AppSecrets;
pub fn create_token<T: Serialize>(secrets: &AppSecrets, claims: T) -> String {
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secrets.jwt_secret.as_bytes())
).expect("Create token");
token
}
pub fn verify_token<T: DeserializeOwned>(secrets: &AppSecrets, jwt: &str) -> Result<T> {
let token_data = decode::<T>(
jwt,
&DecodingKey::from_secret(secrets.jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256)
)?;
Ok(token_data.claims)
}

View file

@ -0,0 +1,11 @@
<footer class="py-3 mt-4 border-top fixed-bottom">
<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>
<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="/about" class="nav-link px-2 text-muted">About</a></li>
<li class="nav-item"><a href="/help" class="nav-link px-2 text-muted">Help</a></li>
</ul>
</div>
</footer>

View file

@ -0,0 +1,34 @@
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">Minauth</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
</ul>
<ul class="navbar-nav">
{% if token_claims %}
<li class="nav-item">
<a class="nav-link" href="/apps">Apps</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/me">Me</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/register">Register</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minauth</title>
<link href="/assets/node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/style/app.css" rel="stylesheet">
</head>
<body>
{% include "components/header.html" %}
<main class="container">
{% block body %}{% endblock %}
</main>
{% include "components/footer.html" %}
</body>
</html>

View file

@ -0,0 +1,34 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Available apps</h1>
<p>List of apps you can use with Single-Sign-On in this organization.</p>
<div class="apps-mosaic">
<div class="row">
{% for app in apps %}
<div class="col-xs-12 col-sm-6 col-lg-3 mb-3 mb-sm-0">
<div class="card">
<div class="card-body">
{% if app.logo_uri %}
<img src="{{ app.logo_uri}}" class="card-img-top" alt="{{ app.name }} logo">
{% endif %}
<h5 class="card-title">
{{ app.name }}
</h5>
<p class="card-text">
{{ app.description }}
</p>
<a
href="{{ app.login_uri }}"
class="btn btn-primary"
title="Open the app or login"
>
Open app
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "layouts/base.html" %}
{% block body %}
<!-- Authorize form -->
{% if error %}
<div>
Error: {{ error }}
</div>
{% endif %}
<form id="authorize-form" method="post" action="/authorize">
<h1>Do you authorize this app?</h1>
<p>
You're about to log-in and give some of your personal data to an application.
If you accept, you will be redirected to "{{ redirect_uri_host }}".
</p>
<ul>
<li>App name: {{ app.name }}</li>
<li>App description: <i>{{ app.description }}</i></li>
<li>Permissions: {{ authorization_params.scope }}</li>
</ul>
<input type="hidden" name="client_id" value="{{ authorization_params.client_id }}" />
<input type="hidden" name="scope" value="{{ authorization_params.scope }}" />
<input type="hidden" name="state" value="{{ authorization_params.state }}" />
<input type="hidden" name="response_type" value="{{ authorization_params.response_type }}" />
<input type="hidden" name="redirect_uri" value="{{ authorization_params.redirect_uri }}" />
<div class="d-flex justify-content-end">
<!-- TODO: implements authorization rejection -->
<a href="/me" class="btn btn-outlined">Don't authorize</a>
<button type="submit" class="btn btn-primary">Authorize</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Bienvenue sur Minauthator</h1>
<p>
Minauthator is free software under <a href="https://www.gnu.org/licenses/gpl-3.0.txt">GPLv3</a> licence.
You can find source code on a <a href="https://forge.lefuturiste.fr/mbess/minauth">self-hosted forge repository</a>.
</p>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Login</h1>
<!-- Login form -->
{% if error %}
<div class="alert alert-danger">
Error: {{ error }}
</div>
{% endif %}
<form id="login-form" method="post">
<div class="mb-3">
<label for="login" class="form-label">Email or username</label>
<input
id="login" name="login" type="text"
required
class="form-control"
/>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
id="password" name="password" type="password"
required
class="form-control"
/>
</div>
<div class="mb-3 form-check">
<input id="keep_session" type="checkbox" class="form-check-input">
<label class="form-check-label" for="keep_session">Check me out</label>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock %}

View file

@ -0,0 +1,72 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Update your user details</h1>
{% if error %}
<div class="alert alert-danger">
Error: {{ error }}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success">
Your details have been updated.
</div>
{% endif %}
<form
id="register-form"
enctype="multipart/form-data"
method="post"
>
<div class="mb-3">
<label for="handle" class="form-label">Handle</label>
<input
id="handle" name="handle" type="text"
minlength="2"
maxlength="255"
required
class="form-control"
value="{{ user.handle }}"
/>
</div>
<div class="mb-3">
<label for="email">Email</label>
<input
id="email" name="email" type="email"
required
class="form-control"
value="{{ user.email }}"
/>
</div>
<div class="mb-3">
<label for="full_name">Full name</label>
<input
id="full_name" name="full_name" type="text"
maxlength="255"
class="form-control"
value="{{ user.full_name or '' }}"
/>
</div>
<div class="mb-3">
<label for="website">Public website</label>
<input
id="website" name="website" type="url"
maxlength="512"
class="form-control"
value="{{ user.website or '' }}"
/>
</div>
<div class="mb-3">
<label for="picture">Profile picture</label>
<!-- for now, no JPEG -->
<input
id="picture" name="picture"
type="file"
accept="image/gif, image/png, image/jpeg"
class="form-control"
>
</div>
<button type="submit" class="btn btn-primary">
Update details
</button>
</form>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Welcome {{ user.full_name or user.handle }}!</h1>
<a href="/me/details-form">Update details.</a>
<a href="/me/authorizations">Manage authorizations.</a>
<p>
{% if user.picture %}
<img src="data:image/*;base64,{{ encode_b64str(user.picture) }}" style="width: 150px; height: 150px; object-fit: contain">
{% endif %}
<ul>
<li>
My user id: {{ user.id }}
</li>
<li>
My handle: {{ user.handle }}
</li>
<li>
My full name: {{ user.full_name }}
</li>
<li>
My email: {{ user.email }}
</li>
</ul>
</p>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "layouts/base.html" %}
{% block body %}
<!-- Register form -->
<h1>Register</h1>
{% if error %}
<div class="alert alert-danger">
Error: {{ error }}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success">
If all the information you submitted are valid and unique, you're account
has been created and we've sent you a confirmation email.
</div>
{% endif %}
<form id="register-form" method="post">
<div class="mb-3">
<label for="handle" class="form-label">Handle</label>
<input
id="handle" name="handle" type="text"
minlength="2"
maxlength="255"
required
class="form-control"
/>
</div>
<div class="mb-3">
<label for="email">Email</label>
<input
id="email" name="email" type="email"
required
class="form-control"
/>
</div>
<div class="mb-3">
<label for="password">Password</label>
<input
id="password" name="password" type="password"
required
class="form-control"
/>
</div>
<button type="submit" class="btn btn-primary">
Register
</button>
</form>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Your authorizations</h1>
<p>
{% if user_authorizations | length == 0 %}
<i>You didn't authorized or accessed any applications for now.</i>
{% endif %}
<ul>
{% for item in user_authorizations %}
<li>
{{ item.client_id }}
Scopes: {{ item.scopes }}
Last_used_at: {{ item.last_used_at }}
<form method="post" action="/me/authorizations/revoke">
<input type="hidden" name="authorization_id" value="{{ item.id }}" />
<button class="btn btn-primary">Revoke</button>
</form>
</li>
{% endfor %}
</ul>
</p>
{% endblock %}

View file

@ -0,0 +1,53 @@
use fully_pub::fully_pub;
use jsonwebtoken::get_current_timestamp;
use kernel::models::authorization::AuthorizationScope;
use serde::{Deserialize, Serialize};
use time::Duration;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct UserTokenClaims {
/// subject: user id
sub: String,
/// token expiration
exp: u64,
/// token issuer
iss: String
// TODO: add roles
}
impl UserTokenClaims {
pub fn new(user_id: &str, max_age: Duration) -> Self {
UserTokenClaims {
sub: user_id.into(),
exp: get_current_timestamp() + max_age.whole_seconds() as u64,
iss: "Minauthator".into()
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct AppUserTokenClaims {
/// combined subject
client_id: String,
user_id: String,
scopes: Vec<AuthorizationScope>,
/// token expiration
exp: u64,
/// token issuer
iss: String
}
impl AppUserTokenClaims {
pub fn new(client_id: &str, user_id: &str, scopes: Vec<AuthorizationScope>) -> Self {
AppUserTokenClaims {
client_id: client_id.into(),
user_id: user_id.into(),
scopes,
exp: get_current_timestamp() + 86_000,
iss: "Minauth".into()
}
}
}