refactor: add renderer middleware + base of roles and authorizations
This commit is contained in:
parent
40b892391a
commit
c277ab3bd9
30 changed files with 374 additions and 137 deletions
|
@ -1,5 +1,7 @@
|
||||||
# Minauthator
|
# Minauthator
|
||||||
|
|
||||||
|
Auth provider supporting [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- [x] register
|
- [x] register
|
||||||
|
|
12
config.toml
12
config.toml
|
@ -7,3 +7,15 @@ slug = "demo_app"
|
||||||
name = "Demo app"
|
name = "Demo app"
|
||||||
client_id = "a1785786-8be1-443c-9a6f-35feed703609"
|
client_id = "a1785786-8be1-443c-9a6f-35feed703609"
|
||||||
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
|
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
|
||||||
|
|
||||||
|
[[roles]]
|
||||||
|
slug = "basic"
|
||||||
|
name = "Basic"
|
||||||
|
description = "Basic user"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[[roles]]
|
||||||
|
slug = "admin"
|
||||||
|
name = "Administrator"
|
||||||
|
description = "Full power on organization instance"
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
# OAuth2 spec
|
# OAuth2 spec
|
||||||
|
|
||||||
https://datatracker.ietf.org/doc/html/rfc6749
|
https://datatracker.ietf.org/doc/html/rfc6749
|
||||||
|
|
||||||
|
https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template
|
||||||
|
|
5
http_integration_tests/.curl-cookies
Normal file
5
http_integration_tests/.curl-cookies
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
localhost FALSE / FALSE 1731761080 minauth_jwt eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwZGZkOThlYy1mYjc3LTRjMWEtOTk5NS00Njg0Y2Y5NDM2NjYiLCJleHAiOjE3MzEyNDI2ODB9.Qnu8UiryN-NZIMk2-YorCuqY5g0ZJwRdszeBe_Y5S3E
|
10
http_integration_tests/authorize.sh
Executable file
10
http_integration_tests/authorize.sh
Executable file
|
@ -0,0 +1,10 @@
|
||||||
|
#!/usr/bin/sh
|
||||||
|
|
||||||
|
curl -v http://localhost:8085/authorize \
|
||||||
|
-G \
|
||||||
|
--cookie ".curl-cookies" \
|
||||||
|
-d client_id="a1785786-8be1-443c-9a6f-35feed703609" \
|
||||||
|
-d response_type="code" \
|
||||||
|
-d redirect_uri="https://localhost:9090/authorize" \
|
||||||
|
-d scope="read_basics" \
|
||||||
|
-d state="qxYAfk4kf6pbZkms78jM"
|
6
http_integration_tests/login.sh
Executable file
6
http_integration_tests/login.sh
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/sh
|
||||||
|
|
||||||
|
curl -v http://localhost:8085/login \
|
||||||
|
--cookie-jar ".curl-cookies" \
|
||||||
|
-d login="test" \
|
||||||
|
-d password="test"
|
6
http_integration_tests/register.sh
Executable file
6
http_integration_tests/register.sh
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/sh
|
||||||
|
|
||||||
|
curl -v http://localhost:8085/register \
|
||||||
|
-d email="test@example.org" \
|
||||||
|
-d handle="test" \
|
||||||
|
-d password="test"
|
|
@ -6,6 +6,7 @@ CREATE TABLE users (
|
||||||
email TEXT UNIQUE,
|
email TEXT UNIQUE,
|
||||||
website TEXT,
|
website TEXT,
|
||||||
picture BLOB,
|
picture BLOB,
|
||||||
|
roles TEXT NOT NULL, -- json array of user roles
|
||||||
|
|
||||||
status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled',
|
status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled',
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
|
@ -13,3 +14,15 @@ CREATE TABLE users (
|
||||||
last_login_at DATETIME,
|
last_login_at DATETIME,
|
||||||
created_at DATETIME NOT NULL
|
created_at DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS authorizations;
|
||||||
|
CREATE TABLE authorizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
scopes TEXT, -- json array of app scope (permissions)
|
||||||
|
|
||||||
|
last_used_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,81 @@
|
||||||
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}, Extension};
|
use axum::{extract::{Query, State}, http::StatusCode, response::{Html, IntoResponse}, Extension, Form};
|
||||||
|
use fully_pub::fully_pub;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{server::AppState, services::session::TokenClaims};
|
use crate::{models::authorization::Authorization, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[fully_pub]
|
||||||
|
/// query params described in [RFC6759 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1)
|
||||||
|
struct AuthorizeQueryParams {
|
||||||
|
response_type: String,
|
||||||
|
client_id: String,
|
||||||
|
scope: String,
|
||||||
|
redirect_uri: String,
|
||||||
|
/// An opaque value used by the client to maintain state between the request and callback
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn authorize_form(
|
pub async fn authorize_form(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
Extension(token_claims): Extension<TokenClaims>
|
Extension(token_claims): Extension<TokenClaims>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
|
query_params: Query<AuthorizeQueryParams>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
// 1. Verify the app details
|
||||||
|
let app_res = app_state.config.applications
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.client_id == query_params.client_id);
|
||||||
|
|
||||||
// 1. Check if the app is already authorized
|
if app_res.is_none() {
|
||||||
// 2. Query the app details
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html("Invalid client_id query params, app not found.")
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
Html(
|
// 2. Check if the app is already authorized
|
||||||
app_state.templating_env.get_template("pages/authorize.html").unwrap()
|
let authorizations_res = sqlx::query_as::<_, Authorization>("SELECT * FROM authorizations WHERE user_id = $1 AND
|
||||||
.render(context!())
|
app_id = $2")
|
||||||
.unwrap()
|
.bind(&token_claims.sub)
|
||||||
)
|
.bind(&query_params.client_id)
|
||||||
|
.fetch_one(&app_state.db)
|
||||||
|
.await
|
||||||
|
.expect("To get authorizations");
|
||||||
|
|
||||||
|
dbg!(authorizations_res);
|
||||||
|
|
||||||
|
// 3. Verify scopes
|
||||||
|
|
||||||
|
// 4. Show form that POST to authorize
|
||||||
|
|
||||||
|
|
||||||
|
renderer
|
||||||
|
.render(
|
||||||
|
"pages/authorize",
|
||||||
|
context!()
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct AuthorizeForm {
|
||||||
|
/// client_id
|
||||||
|
client_id: String,
|
||||||
|
scopes: Vec<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn perform_authorize(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
|
Form(authorize_form): Form<AuthorizeForm>
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Save authorization in DB
|
||||||
|
// 4.1. Create an authorization code
|
||||||
|
// 4.2. Redirect to the app with a token
|
||||||
|
(StatusCode::FOUND, Html("Redirecting…"))
|
||||||
|
}
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
use axum::{extract::State, response::{Html, IntoResponse}};
|
use axum::{response::IntoResponse, Extension};
|
||||||
use axum_macros::debug_handler;
|
use axum_macros::debug_handler;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
|
|
||||||
use crate::server::AppState;
|
use crate::renderer::TemplateRenderer;
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn home(
|
pub async fn home(
|
||||||
State(app_state): State<AppState>
|
Extension(renderer): Extension<TemplateRenderer>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
renderer.render(
|
||||||
app_state.templating_env.get_template("pages/home.html").unwrap()
|
"pages/home",
|
||||||
.render(context!())
|
context!()
|
||||||
.unwrap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,20 @@
|
||||||
use chrono::{Duration, SecondsFormat, Utc};
|
use chrono::{Duration, SecondsFormat, Utc};
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form};
|
use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Extension, Form};
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::user::{User, UserStatus},
|
models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token}
|
||||||
server::AppState,
|
|
||||||
services::{password::verify_password_hash, session::create_token}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn login_form(
|
pub async fn login_form(
|
||||||
State(app_state): State<AppState>
|
Extension(renderer): Extension<TemplateRenderer>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
renderer.render(
|
||||||
app_state.templating_env.get_template("pages/login.html").unwrap()
|
"pages/login",
|
||||||
.render(context!())
|
context!()
|
||||||
.unwrap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,6 +30,7 @@ const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7k
|
||||||
|
|
||||||
pub async fn perform_login(
|
pub async fn perform_login(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
Form(login): Form<LoginForm>
|
Form(login): Form<LoginForm>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// get user from db
|
// get user from db
|
||||||
|
@ -50,15 +48,14 @@ pub async fn perform_login(
|
||||||
Err(_e) => DUMMY_PASSWORD_HASH.into()
|
Err(_e) => DUMMY_PASSWORD_HASH.into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let templ = app_state.templating_env.get_template("pages/login.html").unwrap();
|
|
||||||
|
|
||||||
if verify_password_hash(password_hash, login.password).is_err() {
|
if verify_password_hash(password_hash, login.password).is_err() {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Html(
|
renderer.render(
|
||||||
templ.render(context!(
|
"pages/login",
|
||||||
|
context!(
|
||||||
error => Some("Invalid login or password.".to_string())
|
error => Some("Invalid login or password.".to_string())
|
||||||
)).unwrap()
|
)
|
||||||
)
|
)
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
|
@ -67,10 +64,11 @@ pub async fn perform_login(
|
||||||
if user.status == UserStatus::Disabled {
|
if user.status == UserStatus::Disabled {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Html(
|
renderer.render(
|
||||||
templ.render(context!(
|
"pages/login",
|
||||||
|
context!(
|
||||||
error => Some("This account is disabled.".to_string())
|
error => Some("This account is disabled.".to_string())
|
||||||
)).unwrap()
|
)
|
||||||
)
|
)
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
|
@ -87,13 +85,16 @@ pub async fn perform_login(
|
||||||
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting
|
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting
|
||||||
// is true
|
// is true
|
||||||
let cookie_max_age = Duration::days(7).num_seconds();
|
let cookie_max_age = Duration::days(7).num_seconds();
|
||||||
|
// enforce SameSite=Lax to avoid CSRF
|
||||||
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
|
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
|
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
|
||||||
headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap());
|
headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap());
|
||||||
|
|
||||||
(StatusCode::FOUND, headers, Html(
|
(
|
||||||
templ.render(context!()).unwrap()
|
StatusCode::FOUND,
|
||||||
)).into_response()
|
headers,
|
||||||
|
Html("")
|
||||||
|
).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension};
|
use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension};
|
||||||
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
|
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
|
||||||
use base64::prelude::{Engine, BASE64_STANDARD};
|
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
|
|
||||||
use crate::{models::user::User, server::AppState, services::session::TokenClaims};
|
use crate::{models::user::User, renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
|
||||||
|
|
||||||
pub async fn me_page(
|
pub async fn me_page(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
Extension(token_claims): Extension<TokenClaims>
|
Extension(token_claims): Extension<TokenClaims>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
|
||||||
|
@ -17,19 +17,18 @@ pub async fn me_page(
|
||||||
.await
|
.await
|
||||||
.expect("To get user from claim");
|
.expect("To get user from claim");
|
||||||
|
|
||||||
Html(
|
renderer.render(
|
||||||
app_state.templating_env.get_template("pages/me/index.html").unwrap()
|
"pages/me/index",
|
||||||
.render(context!(
|
context!(
|
||||||
user => user_res,
|
user => user_res
|
||||||
user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x))
|
)
|
||||||
))
|
|
||||||
.unwrap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub async fn me_update_details_form(
|
pub async fn me_update_details_form(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
Extension(token_claims): Extension<TokenClaims>
|
Extension(token_claims): Extension<TokenClaims>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
|
||||||
|
@ -39,12 +38,11 @@ pub async fn me_update_details_form(
|
||||||
.await
|
.await
|
||||||
.expect("To get user from claim");
|
.expect("To get user from claim");
|
||||||
|
|
||||||
Html(
|
renderer.render(
|
||||||
app_state.templating_env.get_template("pages/me/details-form.html").unwrap()
|
"pages/me/details-form",
|
||||||
.render(context!(
|
context!(
|
||||||
user => user_res
|
user => user_res
|
||||||
))
|
)
|
||||||
.unwrap()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,10 +62,11 @@ struct UserDetailsUpdateForm {
|
||||||
|
|
||||||
pub async fn me_perform_update_details(
|
pub async fn me_perform_update_details(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
Extension(token_claims): Extension<TokenClaims>,
|
Extension(token_claims): Extension<TokenClaims>,
|
||||||
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
|
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let template = app_state.templating_env.get_template("pages/me/details-form.html").unwrap();
|
let template_path = "pages/me/details-form";
|
||||||
|
|
||||||
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1")
|
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1")
|
||||||
.bind(&token_claims.sub)
|
.bind(&token_claims.sub)
|
||||||
|
@ -79,8 +78,6 @@ pub async fn me_perform_update_details(
|
||||||
.execute(&app_state.db)
|
.execute(&app_state.db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
dbg!(&update_res);
|
|
||||||
|
|
||||||
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||||
.bind(&token_claims.sub)
|
.bind(&token_claims.sub)
|
||||||
.fetch_one(&app_state.db)
|
.fetch_one(&app_state.db)
|
||||||
|
@ -89,28 +86,23 @@ pub async fn me_perform_update_details(
|
||||||
|
|
||||||
match update_res {
|
match update_res {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
(
|
renderer.render(
|
||||||
StatusCode::OK,
|
template_path,
|
||||||
Html(
|
context!(
|
||||||
template.render(context!(
|
success => true,
|
||||||
success => true,
|
user => user_res
|
||||||
user => user_res
|
|
||||||
))
|
|
||||||
.unwrap()
|
|
||||||
)
|
)
|
||||||
).into_response()
|
)
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
dbg!(&err);
|
dbg!(&err);
|
||||||
(
|
renderer.render(
|
||||||
StatusCode::BAD_REQUEST,
|
template_path,
|
||||||
Html(
|
context!(
|
||||||
template.render(context!(
|
error => Some("Cannot update user details".to_string()),
|
||||||
error => Some("Cannot update user details".to_string()),
|
user => user_res
|
||||||
user => user_res
|
|
||||||
)).unwrap()
|
|
||||||
)
|
)
|
||||||
).into_response()
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
use axum::{extract::State, response::{Html, IntoResponse}, Form};
|
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form};
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use log::{error, info, warn};
|
||||||
|
use serde::Deserialize;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
|
use sqlx::types::Json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{models::user::{User, UserStatus}, server::AppState, services::password::get_password_hash};
|
use crate::{models::user::{User, UserStatus}, renderer::TemplateRenderer, server::AppState, services::password::get_password_hash};
|
||||||
|
|
||||||
pub async fn register_form(
|
pub async fn register_form(
|
||||||
State(app_state): State<AppState>
|
State(app_state): State<AppState>
|
||||||
|
@ -28,22 +30,9 @@ struct RegisterForm {
|
||||||
|
|
||||||
pub async fn perform_register(
|
pub async fn perform_register(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
Form(register): Form<RegisterForm>
|
Form(register): Form<RegisterForm>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let templ = app_state.templating_env.get_template("pages/register.html").unwrap();
|
|
||||||
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
|
|
||||||
.bind(®ister.handle)
|
|
||||||
.bind(®ister.email)
|
|
||||||
.fetch_one(&app_state.db)
|
|
||||||
.await;
|
|
||||||
if user_res.is_ok() {
|
|
||||||
// user already exists
|
|
||||||
return Html(
|
|
||||||
templ.render(context!(
|
|
||||||
success => true
|
|
||||||
)).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let password_hash = Some(
|
let password_hash = Some(
|
||||||
get_password_hash(register.password)
|
get_password_hash(register.password)
|
||||||
|
@ -57,29 +46,55 @@ pub async fn perform_register(
|
||||||
picture: None,
|
picture: None,
|
||||||
|
|
||||||
password_hash,
|
password_hash,
|
||||||
activation_token: None,
|
|
||||||
status: UserStatus::Active,
|
status: UserStatus::Active,
|
||||||
|
roles: Json(Vec::new()), // take the default role in the config
|
||||||
|
activation_token: None,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
website: None,
|
website: None,
|
||||||
last_login_at: None
|
last_login_at: None
|
||||||
};
|
};
|
||||||
// save in DB
|
// save in DB
|
||||||
let _result = sqlx::query("INSERT INTO users (id, handle, email, status, password_hash, created_at) VALUES ($1, $2, $3, $4, $5, $6)")
|
let res = sqlx::query("
|
||||||
|
INSERT INTO users
|
||||||
|
(id, handle, email, status, roles, password_hash, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
")
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(user.handle)
|
.bind(user.handle)
|
||||||
.bind(user.email)
|
.bind(user.email)
|
||||||
.bind(user.status.to_string())
|
.bind(user.status.to_string())
|
||||||
|
.bind(user.roles)
|
||||||
.bind(user.password_hash)
|
.bind(user.password_hash)
|
||||||
.bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
.bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
.execute(&app_state.db)
|
.execute(&app_state.db)
|
||||||
.await.unwrap();
|
.await;
|
||||||
|
match res {
|
||||||
|
Err(err) => {
|
||||||
|
let err_code = err.as_database_error().unwrap().code().unwrap();
|
||||||
|
if err_code == "2067" {
|
||||||
|
warn!("Cannot register user because email or handle is not unique. Failing silently.");
|
||||||
|
} else {
|
||||||
|
error!("Cannot register user: {}", err);
|
||||||
|
return renderer.render_with_status(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"pages/register",
|
||||||
|
context!(
|
||||||
|
error => true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(_v) => {
|
||||||
|
info!("Registered user successfully");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Html(
|
renderer.render_with_status(
|
||||||
templ
|
StatusCode::OK,
|
||||||
.render(context!(
|
"pages/register",
|
||||||
success => true
|
context!(
|
||||||
))
|
success => true
|
||||||
.unwrap()
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ pub mod cli;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod middlewares;
|
pub mod middlewares;
|
||||||
|
pub mod renderer;
|
||||||
|
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
use anyhow::{Result, Context, anyhow};
|
use anyhow::{Result, Context, anyhow};
|
||||||
|
|
|
@ -1,28 +1,49 @@
|
||||||
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response};
|
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::{Html, IntoResponse, Response}, Extension};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
use crate::{server::AppState, services::session::verify_token};
|
use crate::{server::AppState, services::session::{TokenClaims, verify_token}};
|
||||||
|
|
||||||
|
|
||||||
|
/// add optional auth to the extension data
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
cookies: CookieJar,
|
cookies: CookieJar,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, impl IntoResponse> {
|
||||||
let jwt = match cookies.get("minauth_jwt") {
|
let jwt = match cookies.get("minauth_jwt") {
|
||||||
Some(cookie) => cookie.value(),
|
Some(cookie) => cookie.value(),
|
||||||
None => {
|
None => {
|
||||||
// return Err((StatusCode::UNAUTHORIZED, Html("Did not found header")));
|
// no auth found, auth may be optional
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let token_claims = match verify_token(&app_state.secrets, &jwt) {
|
let token_claims = match verify_token(&app_state.secrets, &jwt) {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(
|
||||||
|
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided is invalid."))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
req.extensions_mut().insert(token_claims);
|
req.extensions_mut().insert(token_claims);
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// require auth
|
||||||
|
pub async fn enforce_auth_middleware(
|
||||||
|
token_claims_ext: Option<Extension<TokenClaims>>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, impl IntoResponse> {
|
||||||
|
match token_claims_ext {
|
||||||
|
Some(_val) => (),
|
||||||
|
None => {
|
||||||
|
// auth is required
|
||||||
|
return Err(
|
||||||
|
(StatusCode::UNAUTHORIZED, Html("Unauthorized: auth is required on this page."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod renderer;
|
||||||
|
|
17
src/middlewares/renderer.rs
Normal file
17
src/middlewares/renderer.rs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response, Extension};
|
||||||
|
|
||||||
|
use crate::{renderer::TemplateRenderer, server::AppState, services::session::TokenClaims};
|
||||||
|
|
||||||
|
pub async fn renderer_middleware(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
token_claims_ext: Option<Extension<TokenClaims>>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let renderer_instance = TemplateRenderer {
|
||||||
|
env: app_state.templating_env,
|
||||||
|
token_claims: token_claims_ext.map(|x| x.0)
|
||||||
|
};
|
||||||
|
req.extensions_mut().insert(renderer_instance);
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
|
@ -1,9 +1,11 @@
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::types::Json;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
enum Permissions {
|
#[derive(strum_macros::Display)]
|
||||||
|
enum AuthorizationScope {
|
||||||
ReadBasics
|
ReadBasics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,8 +15,9 @@ struct Authorization {
|
||||||
/// uuid
|
/// uuid
|
||||||
id: String,
|
id: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
app_id: String,
|
/// app_id
|
||||||
permissions: Vec<Permissions>,
|
client_id: String,
|
||||||
|
scopes: Json<Vec<AuthorizationScope>>,
|
||||||
|
|
||||||
last_used_at: Option<DateTime<Utc>>,
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
created_at: DateTime<Utc>
|
created_at: DateTime<Utc>
|
||||||
|
|
|
@ -5,6 +5,8 @@ use fully_pub::fully_pub;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const fn _default_true() -> bool { true }
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
/// Instance branding/customization config
|
/// Instance branding/customization config
|
||||||
|
@ -22,18 +24,31 @@ struct Application {
|
||||||
client_secret: String
|
client_secret: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct Role {
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default = "_default_true")]
|
||||||
|
default: bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: Role hierarchy https://en.wikipedia.org/wiki/Role_hierarchy
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
/// Configuration of this minauthator instance
|
/// Configuration of this minauthator instance
|
||||||
struct Config {
|
struct Config {
|
||||||
/// configure current autotasker instance
|
/// configure current autotasker instance
|
||||||
instance: InstanceConfig,
|
instance: InstanceConfig,
|
||||||
applications: Vec<Application>
|
applications: Vec<Application>,
|
||||||
|
roles: Vec<Role>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
pub struct AppSecrets {
|
struct AppSecrets {
|
||||||
jwt_secret: String
|
jwt_secret: String
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod authorization;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::types::Json;
|
||||||
|
|
||||||
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[derive(strum_macros::Display)]
|
#[derive(strum_macros::Display)]
|
||||||
|
@ -22,9 +23,9 @@ struct User {
|
||||||
picture: Option<Vec<u8>>, // embeded blob to store profile pic
|
picture: Option<Vec<u8>>, // embeded blob to store profile pic
|
||||||
password_hash: Option<String>, // argon2 password hash
|
password_hash: Option<String>, // argon2 password hash
|
||||||
status: UserStatus,
|
status: UserStatus,
|
||||||
|
roles: Json<Vec<String>>,
|
||||||
activation_token: Option<String>,
|
activation_token: Option<String>,
|
||||||
|
|
||||||
last_login_at: Option<DateTime<Utc>>,
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
created_at: DateTime<Utc>
|
created_at: DateTime<Utc>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
45
src/renderer.rs
Normal file
45
src/renderer.rs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
use axum::{http::StatusCode, response::{Html, IntoResponse}};
|
||||||
|
use fully_pub::fully_pub;
|
||||||
|
use log::error;
|
||||||
|
use minijinja::{context, Environment, Value};
|
||||||
|
|
||||||
|
use crate::services::session::TokenClaims;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct TemplateRenderer {
|
||||||
|
env: Environment<'static>,
|
||||||
|
token_claims: Option<TokenClaims>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemplateRenderer {
|
||||||
|
/// Helper method to output HTML as response
|
||||||
|
pub(crate) fn render(&self, name: &str, ctx: Value) -> impl IntoResponse {
|
||||||
|
match self
|
||||||
|
.env
|
||||||
|
.get_template(&format!("{name}.html"))
|
||||||
|
.and_then(|tmpl| tmpl.render(context! {
|
||||||
|
token_claims => self.token_claims,
|
||||||
|
..ctx
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
Ok(content) => Html(content).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
dbg!(err);
|
||||||
|
error!("FATAL: Failed to render template {}", name);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_with_status(&self, status: StatusCode, name: &str, ctx: Value) -> impl IntoResponse {
|
||||||
|
let mut res = self.render(name, ctx).into_response();
|
||||||
|
if res.status().is_server_error() {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
*res.status_mut() = status;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::{middleware, routing::{get, post}, Router};
|
use axum::{middleware, routing::{get, post}, Router};
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}};
|
use crate::{controllers::ui, middlewares::{auth::{auth_middleware, enforce_auth_middleware}, renderer::renderer_middleware}, server::{AppState, ServerConfig}};
|
||||||
|
|
||||||
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
|
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
|
@ -17,11 +17,13 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
|
||||||
.route("/me/details-form", post(ui::me::me_perform_update_details))
|
.route("/me/details-form", post(ui::me::me_perform_update_details))
|
||||||
.route("/logout", get(ui::logout::perform_logout))
|
.route("/logout", get(ui::logout::perform_logout))
|
||||||
.route("/authorize", get(ui::authorize::authorize_form))
|
.route("/authorize", get(ui::authorize::authorize_form))
|
||||||
.layer(middleware::from_fn_with_state(app_state, auth_middleware));
|
.layer(middleware::from_fn_with_state(app_state.clone(), enforce_auth_middleware));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(user_routes)
|
.merge(user_routes)
|
||||||
|
.layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware))
|
||||||
|
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/assets",
|
"/assets",
|
||||||
ServeDir::new(server_config.assets_path.clone())
|
ServeDir::new(server_config.assets_path.clone())
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use base64::{prelude::BASE64_STANDARD, Engine};
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use anyhow::{Result, Context};
|
use anyhow::{Result, Context};
|
||||||
use log::info;
|
use log::info;
|
||||||
|
@ -13,6 +14,9 @@ fn build_templating_env(config: &Config) -> Environment<'static> {
|
||||||
env.add_global("gl", context! {
|
env.add_global("gl", context! {
|
||||||
instance => config.instance
|
instance => config.instance
|
||||||
});
|
});
|
||||||
|
env.add_function("encode_b64str", |bin_val: Vec<u8>| {
|
||||||
|
BASE64_STANDARD.encode(bin_val)
|
||||||
|
});
|
||||||
env
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Val
|
||||||
use crate::models::{config::AppSecrets, user::User};
|
use crate::models::{config::AppSecrets, user::User};
|
||||||
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct TokenClaims {
|
struct TokenClaims {
|
||||||
/// user id
|
/// user id
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
<footer class="py-3 mt-4 border-top fixed-bottom">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center">
|
||||||
<p class="col-md-4 mb-0 text-muted">Minauth</p>
|
<p class="col-md-4 mb-0 text-muted">Minauth</p>
|
||||||
|
|
||||||
<a href="/" class="col-md-4 d-flex align-items-center justify-content-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
|
<ul class="nav col-md-4 justify-content-end">
|
||||||
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
|
<li class="nav-item"><a href="/" class="nav-link px-2 text-muted">Home</a></li>
|
||||||
</a>
|
<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 class="nav col-md-4 justify-content-end">
|
</ul>
|
||||||
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
@ -5,23 +5,25 @@
|
||||||
<div class="collapse navbar-collapse">
|
<div class="collapse navbar-collapse">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav me-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="#">Home</a>
|
<a class="nav-link active" aria-current="page" href="/">Home</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
|
{% if token_claims is none %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/login">Login</a>
|
<a class="nav-link" href="/login">Login</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/register">Register</a>
|
<a class="nav-link" href="/register">Register</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% else %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/me">Me</a>
|
<a class="nav-link" href="/me">Me</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/logout">Logout</a>
|
<a class="nav-link" href="/logout">Logout</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,8 +7,14 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form id="authorize-form" method="post">
|
<form id="authorize-form" method="post">
|
||||||
<input id="keep_session" type="checkbox" class="form-check-input">
|
<h1>Do you authorize this app?</h1>
|
||||||
<label class="form-check-label" for="keep_session">Check me out</label>
|
<ul>
|
||||||
|
<li>App name: </li>
|
||||||
|
<li>Permisions: read basics</li>
|
||||||
|
</ul>
|
||||||
|
<input type="hidden" name="client_id" value="" />
|
||||||
|
<input type="hidden" name="scope" value="" />
|
||||||
|
<input type="hidden" name="state" value="" />
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Authorize</button>
|
<button type="submit" class="btn btn-primary">Authorize</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -5,8 +5,8 @@
|
||||||
<a href="/me/details-form">Update details</a>
|
<a href="/me/details-form">Update details</a>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% if user_picture %}
|
{% if user.picture %}
|
||||||
<img src="data:image/*;base64,{{ user_picture }}" style="width: 150px; height: 150px; object-fit: contain">
|
<img src="data:image/*;base64,{{ encode_b64str(user.picture) }}" style="width: 150px; height: 150px; object-fit: contain">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
class="form-control"
|
class="form-control"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email" name="email" type="email"
|
id="email" name="email" type="email"
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
class="form-control"
|
class="form-control"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password" name="password" type="password"
|
id="password" name="password" type="password"
|
||||||
|
|
Loading…
Reference in a new issue