fix: better JWT cookie management

This commit is contained in:
Matthieu Bessat 2024-11-16 14:30:01 +01:00
parent d70d622e04
commit b20c30048c
12 changed files with 47 additions and 40 deletions

1
Cargo.lock generated
View file

@ -1360,6 +1360,7 @@ dependencies = [
"sqlx", "sqlx",
"strum", "strum",
"strum_macros", "strum_macros",
"time",
"tokio", "tokio",
"toml", "toml",
"totp-rs", "totp-rs",

View file

@ -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
View file

@ -0,0 +1 @@
pub const WEB_GUI_JWT_COOKIE_NAME: &str = "minauthator_jwt";

View file

@ -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()
} }

View file

@ -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("/")
)) )
} }

View file

@ -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)

View file

@ -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};

View file

@ -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)
} }

View file

@ -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())
)
); );
} }
}; };

View file

@ -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()
} }
} }

View file

@ -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>,

View file

@ -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())