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",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"totp-rs",
|
||||
|
|
|
@ -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
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 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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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("/")
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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())
|
||||
|
|
Loading…
Reference in a new issue