refactor: add renderer middleware + base of roles and authorizations

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
# OAuth2 spec
https://datatracker.ietf.org/doc/html/rfc6749
https://stackoverflow.com/questions/79118231/how-to-access-the-axum-request-path-in-a-minijinja-template

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

45
src/renderer.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,23 +5,25 @@
<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>
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
</ul>
<ul class="navbar-nav">
{% if token_claims is none %}
<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>
{% else %}
<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>
{% endif %}
</ul>
</div>
</div>

View file

@ -7,8 +7,14 @@
</div>
{% endif %}
<form id="authorize-form" method="post">
<input id="keep_session" type="checkbox" class="form-check-input">
<label class="form-check-label" for="keep_session">Check me out</label>
<h1>Do you authorize this app?</h1>
<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>
</form>

View file

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

View file

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