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:
parent
69af48bb62
commit
3713cc2443
87 changed files with 834 additions and 474 deletions
51
lib/http_server/Cargo.toml
Normal file
51
lib/http_server/Cargo.toml
Normal 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
3
lib/http_server/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
minijinja_embed::embed_templates!("src/templates");
|
||||
}
|
||||
14
lib/http_server/src/controllers/api/index.rs
Normal file
14
lib/http_server/src/controllers/api/index.rs
Normal 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
|
||||
}))
|
||||
}
|
||||
4
lib/http_server/src/controllers/api/mod.rs
Normal file
4
lib/http_server/src/controllers/api/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod index;
|
||||
pub mod oauth2;
|
||||
pub mod read_user;
|
||||
pub mod openid;
|
||||
91
lib/http_server/src/controllers/api/oauth2/access_token.rs
Normal file
91
lib/http_server/src/controllers/api/oauth2/access_token.rs
Normal 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()
|
||||
}
|
||||
1
lib/http_server/src/controllers/api/oauth2/mod.rs
Normal file
1
lib/http_server/src/controllers/api/oauth2/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod access_token;
|
||||
1
lib/http_server/src/controllers/api/openid/mod.rs
Normal file
1
lib/http_server/src/controllers/api/openid/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod well_known;
|
||||
34
lib/http_server/src/controllers/api/openid/well_known.rs
Normal file
34
lib/http_server/src/controllers/api/openid/well_known.rs
Normal 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()],
|
||||
})
|
||||
}
|
||||
38
lib/http_server/src/controllers/api/read_user.rs
Normal file
38
lib/http_server/src/controllers/api/read_user.rs
Normal 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()
|
||||
}
|
||||
2
lib/http_server/src/controllers/mod.rs
Normal file
2
lib/http_server/src/controllers/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod api;
|
||||
pub mod ui;
|
||||
0
lib/http_server/src/controllers/ui/admin/apps.rs
Normal file
0
lib/http_server/src/controllers/ui/admin/apps.rs
Normal file
0
lib/http_server/src/controllers/ui/admin/mod.rs
Normal file
0
lib/http_server/src/controllers/ui/admin/mod.rs
Normal file
0
lib/http_server/src/controllers/ui/admin/users.rs
Normal file
0
lib/http_server/src/controllers/ui/admin/users.rs
Normal file
32
lib/http_server/src/controllers/ui/apps.rs
Normal file
32
lib/http_server/src/controllers/ui/apps.rs
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
233
lib/http_server/src/controllers/ui/authorize.rs
Normal file
233
lib/http_server/src/controllers/ui/authorize.rs
Normal 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()
|
||||
}
|
||||
16
lib/http_server/src/controllers/ui/home.rs
Normal file
16
lib/http_server/src/controllers/ui/home.rs
Normal 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!()
|
||||
)
|
||||
}
|
||||
|
||||
113
lib/http_server/src/controllers/ui/login.rs
Normal file
113
lib/http_server/src/controllers/ui/login.rs
Normal 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()
|
||||
}
|
||||
|
||||
14
lib/http_server/src/controllers/ui/logout.rs
Normal file
14
lib/http_server/src/controllers/ui/logout.rs
Normal 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("/")
|
||||
)
|
||||
}
|
||||
|
||||
115
lib/http_server/src/controllers/ui/me.rs
Normal file
115
lib/http_server/src/controllers/ui/me.rs
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
8
lib/http_server/src/controllers/ui/mod.rs
Normal file
8
lib/http_server/src/controllers/ui/mod.rs
Normal 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;
|
||||
104
lib/http_server/src/controllers/ui/register.rs
Normal file
104
lib/http_server/src/controllers/ui/register.rs
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
1
lib/http_server/src/controllers/ui/user_panel/mod.rs
Normal file
1
lib/http_server/src/controllers/ui/user_panel/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod authorizations;
|
||||
67
lib/http_server/src/lib.rs
Normal file
67
lib/http_server/src/lib.rs
Normal 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(())
|
||||
}
|
||||
49
lib/http_server/src/main.rs
Normal file
49
lib/http_server/src/main.rs
Normal 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
|
||||
}
|
||||
115
lib/http_server/src/middlewares/app_auth.rs
Normal file
115
lib/http_server/src/middlewares/app_auth.rs
Normal 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)
|
||||
}
|
||||
3
lib/http_server/src/middlewares/mod.rs
Normal file
3
lib/http_server/src/middlewares/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod user_auth;
|
||||
pub mod app_auth;
|
||||
pub mod renderer;
|
||||
16
lib/http_server/src/middlewares/renderer.rs
Normal file
16
lib/http_server/src/middlewares/renderer.rs
Normal 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)
|
||||
}
|
||||
69
lib/http_server/src/middlewares/user_auth.rs
Normal file
69
lib/http_server/src/middlewares/user_auth.rs
Normal 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)
|
||||
}
|
||||
58
lib/http_server/src/renderer.rs
Normal file
58
lib/http_server/src/renderer.rs
Normal 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
|
||||
}
|
||||
62
lib/http_server/src/router.rs
Normal file
62
lib/http_server/src/router.rs
Normal 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())
|
||||
)
|
||||
}
|
||||
12
lib/http_server/src/services/app_session.rs
Normal file
12
lib/http_server/src/services/app_session.rs
Normal 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
|
||||
}
|
||||
|
||||
3
lib/http_server/src/services/mod.rs
Normal file
3
lib/http_server/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod session;
|
||||
pub mod oauth2;
|
||||
pub mod app_session;
|
||||
20
lib/http_server/src/services/oauth2.rs
Normal file
20
lib/http_server/src/services/oauth2.rs
Normal 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)
|
||||
}
|
||||
25
lib/http_server/src/services/session.rs
Normal file
25
lib/http_server/src/services/session.rs
Normal 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)
|
||||
}
|
||||
11
lib/http_server/src/templates/components/footer.html
Normal file
11
lib/http_server/src/templates/components/footer.html
Normal 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>
|
||||
34
lib/http_server/src/templates/components/header.html
Normal file
34
lib/http_server/src/templates/components/header.html
Normal 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>
|
||||
18
lib/http_server/src/templates/layouts/base.html
Normal file
18
lib/http_server/src/templates/layouts/base.html
Normal 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>
|
||||
|
||||
34
lib/http_server/src/templates/pages/apps.html
Normal file
34
lib/http_server/src/templates/pages/apps.html
Normal 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 %}
|
||||
|
||||
32
lib/http_server/src/templates/pages/authorize.html
Normal file
32
lib/http_server/src/templates/pages/authorize.html
Normal 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 %}
|
||||
11
lib/http_server/src/templates/pages/home.html
Normal file
11
lib/http_server/src/templates/pages/home.html
Normal 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 %}
|
||||
|
||||
34
lib/http_server/src/templates/pages/login.html
Normal file
34
lib/http_server/src/templates/pages/login.html
Normal 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 %}
|
||||
72
lib/http_server/src/templates/pages/me/details-form.html
Normal file
72
lib/http_server/src/templates/pages/me/details-form.html
Normal 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 %}
|
||||
28
lib/http_server/src/templates/pages/me/index.html
Normal file
28
lib/http_server/src/templates/pages/me/index.html
Normal 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 %}
|
||||
|
||||
47
lib/http_server/src/templates/pages/register.html
Normal file
47
lib/http_server/src/templates/pages/register.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
||||
53
lib/http_server/src/token_claims.rs
Normal file
53
lib/http_server/src/token_claims.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue