fix: better JWT cookie management
This commit is contained in:
parent
d70d622e04
commit
b20c30048c
12 changed files with 47 additions and 40 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -1360,6 +1360,7 @@ dependencies = [
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"totp-rs",
|
"totp-rs",
|
||||||
|
|
|
@ -57,6 +57,7 @@ totp-rs = "5.6"
|
||||||
minijinja-embed = "2.3.1"
|
minijinja-embed = "2.3.1"
|
||||||
axum-macros = "0.4.2"
|
axum-macros = "0.4.2"
|
||||||
jsonwebtoken = "9.3.0"
|
jsonwebtoken = "9.3.0"
|
||||||
|
time = "0.3.36"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
minijinja-embed = "2.3.1"
|
minijinja-embed = "2.3.1"
|
||||||
|
|
1
src/consts.rs
Normal file
1
src/consts.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub const WEB_GUI_JWT_COOKIE_NAME: &str = "minauthator_jwt";
|
|
@ -1,12 +1,14 @@
|
||||||
use chrono::{Duration, SecondsFormat, Utc};
|
use axum_extra::extract::{cookie::{Cookie, SameSite}, CookieJar};
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use axum::{extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Extension, Form};
|
use axum::{extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse, Redirect}, Extension, Form};
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{token_claims::UserTokenClaims, user::{User, UserStatus}}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token}
|
consts::WEB_GUI_JWT_COOKIE_NAME, models::{token_claims::UserTokenClaims, user::{User, UserStatus}}, renderer::TemplateRenderer, server::AppState, services::{password::verify_password_hash, session::create_token}
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn login_form(
|
pub async fn login_form(
|
||||||
|
@ -37,6 +39,7 @@ struct LoginQueryParams {
|
||||||
pub async fn perform_login(
|
pub async fn perform_login(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
Extension(renderer): Extension<TemplateRenderer>,
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
|
cookies: CookieJar,
|
||||||
Query(query_params): Query<LoginQueryParams>,
|
Query(query_params): Query<LoginQueryParams>,
|
||||||
Form(login): Form<LoginForm>
|
Form(login): Form<LoginForm>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
@ -87,24 +90,24 @@ pub async fn perform_login(
|
||||||
.execute(&app_state.db)
|
.execute(&app_state.db)
|
||||||
.await.unwrap();
|
.await.unwrap();
|
||||||
|
|
||||||
let jwt = create_token(&app_state.secrets, UserTokenClaims::from_user_id(&user.id));
|
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
|
// 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 = jwt_max_age - Duration::seconds(32);
|
||||||
// enforce SameSite=Lax to avoid CSRF
|
// enforce SameSite=Lax to avoid CSRF
|
||||||
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
|
let jwt_cookie = Cookie::build((WEB_GUI_JWT_COOKIE_NAME, jwt))
|
||||||
let mut headers = HeaderMap::new();
|
.same_site(SameSite::Lax)
|
||||||
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
|
.max_age(cookie_max_age);
|
||||||
|
|
||||||
// TODO: check redirection for arbitrary URL, enforce relative path
|
// TODO: check redirection for arbitrary URL, enforce relative path
|
||||||
headers.insert("Location", HeaderValue::from_str(
|
let redirection_target = query_params.redirect_to.unwrap_or("/me".to_string());
|
||||||
&query_params.redirect_to.unwrap_or("/me".to_string())
|
|
||||||
).unwrap());
|
|
||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::SEE_OTHER,
|
cookies.add(jwt_cookie),
|
||||||
headers,
|
Redirect::to(&redirection_target)
|
||||||
Html("Logged in. Redirecting you.")
|
|
||||||
).into_response()
|
).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
use axum::{http::StatusCode, response::Redirect};
|
use axum::response::{IntoResponse, Redirect};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
|
use crate::consts::WEB_GUI_JWT_COOKIE_NAME;
|
||||||
|
|
||||||
pub async fn perform_logout(
|
pub async fn perform_logout(
|
||||||
cookies: CookieJar
|
cookies: CookieJar
|
||||||
) -> Result<(CookieJar, Redirect), StatusCode> {
|
) -> impl IntoResponse {
|
||||||
Ok((
|
(
|
||||||
cookies.remove("minauth_jwt"),
|
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
|
||||||
Redirect::to("/")
|
Redirect::to("/")
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,6 @@ pub async fn me_page(
|
||||||
Extension(renderer): Extension<TemplateRenderer>,
|
Extension(renderer): Extension<TemplateRenderer>,
|
||||||
Extension(token_claims): Extension<UserTokenClaims>
|
Extension(token_claims): Extension<UserTokenClaims>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -8,6 +8,7 @@ pub mod utils;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod middlewares;
|
pub mod middlewares;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
|
pub mod consts;
|
||||||
|
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
use anyhow::{Result, Context, anyhow};
|
use anyhow::{Result, Context, anyhow};
|
||||||
|
|
|
@ -12,6 +12,7 @@ pub async fn renderer_middleware(
|
||||||
env: app_state.templating_env,
|
env: app_state.templating_env,
|
||||||
token_claims: token_claims_ext.map(|x| x.0)
|
token_claims: token_claims_ext.map(|x| x.0)
|
||||||
};
|
};
|
||||||
|
dbg!(&renderer_instance);
|
||||||
req.extensions_mut().insert(renderer_instance);
|
req.extensions_mut().insert(renderer_instance);
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{OriginalUri, Request, State},
|
extract::{OriginalUri, Request, State},
|
||||||
http::{HeaderMap, HeaderValue, StatusCode},
|
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
Extension
|
Extension
|
||||||
};
|
};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::token_claims::UserTokenClaims,
|
consts::WEB_GUI_JWT_COOKIE_NAME, models::token_claims::UserTokenClaims, server::AppState, services::session::verify_token
|
||||||
server::AppState,
|
|
||||||
services::session::verify_token
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,7 +19,7 @@ pub async fn auth_middleware(
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, impl IntoResponse> {
|
) -> Result<Response, impl IntoResponse> {
|
||||||
let jwt = match cookies.get("minauth_jwt") {
|
let jwt = match cookies.get(WEB_GUI_JWT_COOKIE_NAME) {
|
||||||
Some(cookie) => cookie.value(),
|
Some(cookie) => cookie.value(),
|
||||||
None => {
|
None => {
|
||||||
// no auth found, auth may be optional
|
// no auth found, auth may be optional
|
||||||
|
@ -33,12 +30,11 @@ pub async fn auth_middleware(
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
// UserWebGUI: delete invalid JWT cookie
|
// UserWebGUI: delete invalid JWT cookie
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
let jwt_cookie = "minauth_jwt=deleted; SameSite=Lax; Max-Age=0".to_string();
|
|
||||||
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
|
|
||||||
headers.insert("Location", HeaderValue::from_str(&original_uri.to_string()).unwrap());
|
|
||||||
return Err(
|
return Err(
|
||||||
(StatusCode::SEE_OTHER, headers, Html("Unauthorized: Invalid JWT cookie."))
|
(
|
||||||
|
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
|
||||||
|
Redirect::to(&original_uri.to_string())
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use jsonwebtoken::get_current_timestamp;
|
use jsonwebtoken::get_current_timestamp;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
use super::authorization::AuthorizationScope;
|
use super::authorization::AuthorizationScope;
|
||||||
|
|
||||||
|
@ -17,10 +18,10 @@ struct UserTokenClaims {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserTokenClaims {
|
impl UserTokenClaims {
|
||||||
pub fn from_user_id(user_id: &str) -> Self {
|
pub fn new(user_id: &str, max_age: Duration) -> Self {
|
||||||
UserTokenClaims {
|
UserTokenClaims {
|
||||||
sub: user_id.into(),
|
sub: user_id.into(),
|
||||||
exp: get_current_timestamp() + 86_000,
|
exp: get_current_timestamp() + max_age.whole_seconds() as u64,
|
||||||
iss: "Minauthator".into()
|
iss: "Minauthator".into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ use minijinja::{context, Environment, Value};
|
||||||
use crate::models::token_claims::UserTokenClaims;
|
use crate::models::token_claims::UserTokenClaims;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct TemplateRenderer {
|
struct TemplateRenderer {
|
||||||
env: Environment<'static>,
|
env: Environment<'static>,
|
||||||
|
|
|
@ -19,6 +19,7 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
|
||||||
.route("/register", post(ui::register::perform_register))
|
.route("/register", post(ui::register::perform_register))
|
||||||
.route("/login", get(ui::login::login_form))
|
.route("/login", get(ui::login::login_form))
|
||||||
.route("/login", post(ui::login::perform_login))
|
.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));
|
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware));
|
||||||
|
|
||||||
let user_routes = Router::new()
|
let user_routes = Router::new()
|
||||||
|
@ -28,15 +29,16 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
|
||||||
.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))
|
||||||
.route("/authorize", post(ui::authorize::perform_authorize))
|
.route("/authorize", post(ui::authorize::perform_authorize))
|
||||||
|
.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::enforce_auth_middleware))
|
||||||
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware));
|
.layer(middleware::from_fn_with_state(app_state.clone(), user_auth::auth_middleware));
|
||||||
|
|
||||||
let app_routes = Router::new()
|
let api_app_routes = Router::new()
|
||||||
.route("/api/token", post(api::oauth2::access_token::get_access_token))
|
.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::enforce_basic_auth_middleware))
|
||||||
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::basic_auth_middleware));
|
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::basic_auth_middleware));
|
||||||
|
|
||||||
let app_user_routes = Router::new()
|
let api_user_routes = Router::new()
|
||||||
.route("/api/user", get(api::read_user::read_user_basic))
|
.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));
|
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware));
|
||||||
|
|
||||||
|
@ -46,10 +48,9 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(user_routes)
|
.merge(user_routes)
|
||||||
.merge(app_routes)
|
.merge(api_app_routes)
|
||||||
.merge(app_user_routes)
|
.merge(api_user_routes)
|
||||||
.merge(well_known_routes)
|
.merge(well_known_routes)
|
||||||
.layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware))
|
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/assets",
|
"/assets",
|
||||||
ServeDir::new(server_config.assets_path.clone())
|
ServeDir::new(server_config.assets_path.clone())
|
||||||
|
|
Loading…
Reference in a new issue