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",
"strum",
"strum_macros",
"time",
"tokio",
"toml",
"totp-rs",

View file

@ -57,6 +57,7 @@ totp-rs = "5.6"
minijinja-embed = "2.3.1"
axum-macros = "0.4.2"
jsonwebtoken = "9.3.0"
time = "0.3.36"
[build-dependencies]
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 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 minijinja::context;
use time::Duration;
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(
@ -37,6 +39,7 @@ struct LoginQueryParams {
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 {
@ -87,24 +90,24 @@ pub async fn perform_login(
.execute(&app_state.db)
.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
// 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
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());
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
headers.insert("Location", HeaderValue::from_str(
&query_params.redirect_to.unwrap_or("/me".to_string())
).unwrap());
let redirection_target = query_params.redirect_to.unwrap_or("/me".to_string());
(
StatusCode::SEE_OTHER,
headers,
Html("Logged in. Redirecting you.")
cookies.add(jwt_cookie),
Redirect::to(&redirection_target)
).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 crate::consts::WEB_GUI_JWT_COOKIE_NAME;
pub async fn perform_logout(
cookies: CookieJar
) -> Result<(CookieJar, Redirect), StatusCode> {
Ok((
cookies.remove("minauth_jwt"),
) -> impl IntoResponse {
(
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
Redirect::to("/")
))
)
}

View file

@ -15,7 +15,6 @@ pub async fn me_page(
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)

View file

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

View file

@ -12,6 +12,7 @@ pub async fn renderer_middleware(
env: app_state.templating_env,
token_claims: token_claims_ext.map(|x| x.0)
};
dbg!(&renderer_instance);
req.extensions_mut().insert(renderer_instance);
Ok(next.run(req).await)
}

View file

@ -1,16 +1,13 @@
use axum::{
extract::{OriginalUri, Request, State},
http::{HeaderMap, HeaderValue, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
response::{IntoResponse, Redirect, Response},
Extension
};
use axum_extra::extract::CookieJar;
use crate::{
models::token_claims::UserTokenClaims,
server::AppState,
services::session::verify_token
consts::WEB_GUI_JWT_COOKIE_NAME, models::token_claims::UserTokenClaims, server::AppState, services::session::verify_token
};
@ -22,7 +19,7 @@ pub async fn auth_middleware(
mut req: Request,
next: Next,
) -> 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(),
None => {
// no auth found, auth may be optional
@ -33,12 +30,11 @@ pub async fn auth_middleware(
Ok(val) => val,
Err(_e) => {
// 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(
(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 jsonwebtoken::get_current_timestamp;
use serde::{Deserialize, Serialize};
use time::Duration;
use super::authorization::AuthorizationScope;
@ -17,10 +18,10 @@ struct UserTokenClaims {
}
impl UserTokenClaims {
pub fn from_user_id(user_id: &str) -> Self {
pub fn new(user_id: &str, max_age: Duration) -> Self {
UserTokenClaims {
sub: user_id.into(),
exp: get_current_timestamp() + 86_000,
exp: get_current_timestamp() + max_age.whole_seconds() as u64,
iss: "Minauthator".into()
}
}

View file

@ -6,7 +6,7 @@ use minijinja::{context, Environment, Value};
use crate::models::token_claims::UserTokenClaims;
#[derive(Clone)]
#[derive(Debug, Clone)]
#[fully_pub]
struct TemplateRenderer {
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("/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()
@ -28,15 +29,16 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
.route("/logout", get(ui::logout::perform_logout))
.route("/authorize", get(ui::authorize::authorize_form))
.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::auth_middleware));
let app_routes = Router::new()
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 app_user_routes = Router::new()
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));
@ -46,10 +48,9 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
Router::new()
.merge(public_routes)
.merge(user_routes)
.merge(app_routes)
.merge(app_user_routes)
.merge(api_app_routes)
.merge(api_user_routes)
.merge(well_known_routes)
.layer(middleware::from_fn_with_state(app_state.clone(), renderer_middleware))
.nest_service(
"/assets",
ServeDir::new(server_config.assets_path.clone())