From 9687116063f3a0679ec697bc0cc6a982f05d5274 Mon Sep 17 00:00:00 2001 From: Philippe Loctaux Date: Sun, 26 Mar 2023 19:25:50 +0200 Subject: [PATCH] ezidam: refactor jwt and refresh token generate in "tokens" mod --- .../queries/keys/revoke_all_except_one.sql | 6 ++ .../queries/refresh_tokens/revoke_all.sql | 5 ++ crates/database/sqlx-data.json | 20 +++++ crates/database/src/tables/keys.rs | 13 +++ crates/database/src/tables/refresh_tokens.rs | 9 +++ crates/ezidam/src/guards/jwt.rs | 26 +++--- crates/ezidam/src/guards/refresh_token.rs | 3 +- crates/ezidam/src/lib.rs | 1 + crates/ezidam/src/routes/oauth/redirect.rs | 60 ++++---------- crates/ezidam/src/routes/root.rs | 5 +- crates/ezidam/src/tokens.rs | 79 +++++++++++++++++++ crates/jwt/src/database.rs | 7 ++ crates/refresh_tokens/src/database.rs | 4 + 13 files changed, 179 insertions(+), 59 deletions(-) create mode 100644 crates/database/queries/keys/revoke_all_except_one.sql create mode 100644 crates/database/queries/refresh_tokens/revoke_all.sql create mode 100644 crates/ezidam/src/tokens.rs diff --git a/crates/database/queries/keys/revoke_all_except_one.sql b/crates/database/queries/keys/revoke_all_except_one.sql new file mode 100644 index 0000000..0c8f96d --- /dev/null +++ b/crates/database/queries/keys/revoke_all_except_one.sql @@ -0,0 +1,6 @@ +update keys + +set revoked_at = CURRENT_TIMESTAMP + +where revoked_at is null + and id is not (?) diff --git a/crates/database/queries/refresh_tokens/revoke_all.sql b/crates/database/queries/refresh_tokens/revoke_all.sql new file mode 100644 index 0000000..ad55e35 --- /dev/null +++ b/crates/database/queries/refresh_tokens/revoke_all.sql @@ -0,0 +1,5 @@ +update refresh_tokens + +set revoked_at = CURRENT_TIMESTAMP + +where revoked_at is null \ No newline at end of file diff --git a/crates/database/sqlx-data.json b/crates/database/sqlx-data.json index f979ba4..40bc2bc 100644 --- a/crates/database/sqlx-data.json +++ b/crates/database/sqlx-data.json @@ -338,6 +338,16 @@ }, "query": "select id,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n is_admin as \"is_admin: bool\",\n username,\n name,\n email,\n password,\n password_recover,\n paper_key,\n is_archived as \"is_archived: bool\"\nfrom users\n\nwhere email is (?)\n" }, + "7b7f2430b2a719b3d5ce504c0a9302731b3ff82da99ba7771c2728d88aee642a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "update keys\n\nset revoked_at = CURRENT_TIMESTAMP\n\nwhere revoked_at is null\n and id is not (?)\n" + }, "7f26b73408318040f94fb6574d5cc25482cef1a57ba4c467fa0bc0fdf25bf39c": { "describe": { "columns": [], @@ -358,6 +368,16 @@ }, "query": "update settings\n\nset url = ?\n\nwhere id is 0\n" }, + "9f1885c4786f73335b4d614f562bb7cad49c91bfe7f084d8c25c6c571673ab90": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 0 + } + }, + "query": "update refresh_tokens\n\nset revoked_at = CURRENT_TIMESTAMP\n\nwhere revoked_at is null" + }, "a55b17a3a70e6445517f19536220f0dafc78a0e8b69221dee4715f84841839da": { "describe": { "columns": [], diff --git a/crates/database/src/tables/keys.rs b/crates/database/src/tables/keys.rs index fe664fa..3feb7fa 100644 --- a/crates/database/src/tables/keys.rs +++ b/crates/database/src/tables/keys.rs @@ -63,4 +63,17 @@ impl Keys { } } } + + pub async fn revoke_all_except_one( + conn: impl SqliteExecutor<'_>, + exception: &str, + ) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/keys/revoke_all_except_one.sql", exception) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() >= 1).then_some(())) + } } diff --git a/crates/database/src/tables/refresh_tokens.rs b/crates/database/src/tables/refresh_tokens.rs index c17517e..ed6d46b 100644 --- a/crates/database/src/tables/refresh_tokens.rs +++ b/crates/database/src/tables/refresh_tokens.rs @@ -59,6 +59,15 @@ impl RefreshTokens { Ok((query.rows_affected() == 1).then_some(())) } + pub async fn revoke_all(conn: impl SqliteExecutor<'_>) -> Result, Error> { + let query: SqliteQueryResult = sqlx::query_file!("queries/refresh_tokens/revoke_all.sql") + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() >= 1).then_some(())) + } + pub async fn revoke_all_for_user( conn: impl SqliteExecutor<'_>, user: &str, diff --git a/crates/ezidam/src/guards/jwt.rs b/crates/ezidam/src/guards/jwt.rs index d68ee50..7781717 100644 --- a/crates/ezidam/src/guards/jwt.rs +++ b/crates/ezidam/src/guards/jwt.rs @@ -10,6 +10,9 @@ mod admin; mod user; use crate::guards::refresh_token::get_refresh_token_from_cookie; +use crate::tokens::{ + JWT_COOKIE_NAME, JWT_DURATION_MINUTES, REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_DURATION_DAYS, +}; pub use admin::JwtAdmin; use id::KeyID; pub use user::JwtUser; @@ -50,7 +53,7 @@ pub enum Error { pub(super) fn get_access_token_from_cookie(request: &Request) -> Option { request .cookies() - .get("access_token") + .get(JWT_COOKIE_NAME) .map(|cookie| cookie.value().to_string()) } @@ -261,9 +264,6 @@ pub async fn use_refresh_token( } }; - // Refresh token duration in days - let refresh_token_duration = 21; - // Attempt to get ip address let ip_address = match request.client_ip() { Some(ip) => ip.to_string(), @@ -278,7 +278,7 @@ pub async fn use_refresh_token( new_refresh_token.as_ref(), ip_address, user.id(), - refresh_token_duration, + REFRESH_TOKEN_DURATION_DAYS, ) .await { @@ -286,11 +286,14 @@ pub async fn use_refresh_token( } // Add refresh token as a cookie - let mut cookie = Cookie::new("refresh_token", new_refresh_token.as_ref().to_string()); + let mut cookie = Cookie::new( + REFRESH_TOKEN_COOKIE_NAME, + new_refresh_token.as_ref().to_string(), + ); cookie.set_secure(true); cookie.set_http_only(true); cookie.set_same_site(SameSite::Strict); - cookie.set_max_age(Duration::days(refresh_token_duration)); + cookie.set_max_age(Duration::days(REFRESH_TOKEN_DURATION_DAYS)); cookie_jar.add(cookie); // Get latest key from database @@ -336,14 +339,11 @@ pub async fn use_refresh_token( // TODO: get user roles let roles = vec![]; - // Access token duration in minutes - let access_token_duration = 1; - // Create jwt, sign and serialize let jwt_claims = JwtClaims::new(home_page.clone(), "ezidam", &user, roles); let jwt = match jwt_claims .clone() - .sign_serialize(&private_key, access_token_duration) + .sign_serialize(&private_key, JWT_DURATION_MINUTES) { Ok(jwt) => jwt, Err(e) => { @@ -352,11 +352,11 @@ pub async fn use_refresh_token( }; // Add jwt as a cookie - let mut cookie = Cookie::new("access_token", jwt); + let mut cookie = Cookie::new(JWT_COOKIE_NAME, jwt); cookie.set_secure(true); cookie.set_http_only(true); cookie.set_same_site(SameSite::Strict); - cookie.set_max_age(Duration::minutes(access_token_duration)); + cookie.set_max_age(Duration::minutes(JWT_DURATION_MINUTES)); cookie_jar.add(cookie); if let Err(_e) = transaction.commit().await { diff --git a/crates/ezidam/src/guards/refresh_token.rs b/crates/ezidam/src/guards/refresh_token.rs index 4ab6f00..c92eac4 100644 --- a/crates/ezidam/src/guards/refresh_token.rs +++ b/crates/ezidam/src/guards/refresh_token.rs @@ -1,10 +1,11 @@ +use crate::tokens::REFRESH_TOKEN_COOKIE_NAME; use rocket::request::{FromRequest, Outcome}; use rocket::Request; pub struct RefreshToken(pub String); pub fn get_refresh_token_from_cookie(request: &Request) -> Option { - match request.cookies().get("refresh_token") { + match request.cookies().get(REFRESH_TOKEN_COOKIE_NAME) { Some(cookie) => { let value = cookie.value(); diff --git a/crates/ezidam/src/lib.rs b/crates/ezidam/src/lib.rs index 3c9447c..ae532b9 100644 --- a/crates/ezidam/src/lib.rs +++ b/crates/ezidam/src/lib.rs @@ -12,6 +12,7 @@ mod page; mod response_timer; mod routes; mod shutdown; +mod tokens; #[cfg(test)] mod tests; diff --git a/crates/ezidam/src/routes/oauth/redirect.rs b/crates/ezidam/src/routes/oauth/redirect.rs index 40548d3..df8bf5b 100644 --- a/crates/ezidam/src/routes/oauth/redirect.rs +++ b/crates/ezidam/src/routes/oauth/redirect.rs @@ -1,12 +1,11 @@ use crate::routes::prelude::*; +use crate::tokens::{generate_jwt, generate_refresh_token}; use apps::App; use authorization_codes::AuthorizationCode; -use hash::SecretString; use jwt::database::Key; -use jwt::{JwtClaims, PrivateKey}; +use jwt::PrivateKey; use refresh_tokens::RefreshToken; -use rocket::http::{Cookie, CookieJar, SameSite}; -use rocket::time::Duration; +use rocket::http::CookieJar; use rocket::{get, UriDisplayQuery}; use settings::Settings; use std::net::IpAddr; @@ -80,28 +79,9 @@ pub async fn redirect_page( // TODO: refactor for "code" route // Generate refresh token - let refresh_token = task::spawn_blocking(|| SecretString::new(64)).await?; - - // Refresh token duration in days - let refresh_token_duration = 21; - - // Insert refresh token in database - RefreshToken::insert( - &mut transaction, - refresh_token.as_ref(), - ip_address.to_string(), - user.id(), - refresh_token_duration, - ) - .await?; - - // Add refresh token as a cookie - let mut cookie = Cookie::new("refresh_token", refresh_token.as_ref().to_string()); - cookie.set_secure(true); - cookie.set_http_only(true); - cookie.set_same_site(SameSite::Strict); - cookie.set_max_age(Duration::days(refresh_token_duration)); - cookie_jar.add(cookie); + generate_refresh_token(&mut transaction, ip_address, user.id(), cookie_jar) + .await + .map_err(Error::internal_server_error)?; // Get latest key from database let key = Key::get_most_recent(&mut transaction) @@ -118,23 +98,17 @@ pub async fn redirect_page( task::spawn_blocking(move || PrivateKey::from_der(key.private_der(), key.key_id())) .await??; - // TODO: get user roles - let roles = vec![]; - - // Access token duration in minutes - let access_token_duration = 15; - - // Create jwt, sign and serialize - let jwt = JwtClaims::new(home_page.clone(), app.id().as_ref(), &user, roles) - .sign_serialize(&private_key, access_token_duration)?; - - // Add jwt as a cookie - let mut cookie = Cookie::new("access_token", jwt); - cookie.set_secure(true); - cookie.set_http_only(true); - cookie.set_same_site(SameSite::Strict); - cookie.set_max_age(Duration::minutes(access_token_duration)); - cookie_jar.add(cookie); + // Generate jwt + generate_jwt( + &mut transaction, + &private_key, + &home_page, + &app.id().0, + &user, + cookie_jar, + ) + .await + .map_err(Error::internal_server_error)?; } transaction.commit().await?; diff --git a/crates/ezidam/src/routes/root.rs b/crates/ezidam/src/routes/root.rs index 7d308f1..19b75ae 100644 --- a/crates/ezidam/src/routes/root.rs +++ b/crates/ezidam/src/routes/root.rs @@ -1,4 +1,5 @@ use super::prelude::*; +use crate::tokens::{JWT_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME}; use rocket::http::{Cookie, CookieJar}; use rocket::{get, post}; use settings::Settings; @@ -124,8 +125,8 @@ async fn logout( .ok_or_else(|| Error::not_found("Unknown refresh token"))?; // Delete cookies - cookie_jar.remove(Cookie::named("access_token")); - cookie_jar.remove(Cookie::named("refresh_token")); + cookie_jar.remove(Cookie::named(JWT_COOKIE_NAME)); + cookie_jar.remove(Cookie::named(REFRESH_TOKEN_COOKIE_NAME)); // If refresh token has already been used if refresh_token.has_been_used() { diff --git a/crates/ezidam/src/tokens.rs b/crates/ezidam/src/tokens.rs new file mode 100644 index 0000000..f7107d6 --- /dev/null +++ b/crates/ezidam/src/tokens.rs @@ -0,0 +1,79 @@ +use hash::SecretString; +use id::UserID; +use jwt::{JwtClaims, PrivateKey}; +use refresh_tokens::RefreshToken; +use rocket::http::{Cookie, CookieJar, SameSite}; +use rocket::time::Duration; +use rocket::tokio::task; +use rocket_db_pools::sqlx::SqliteExecutor; +use std::net::IpAddr; +use users::User; + +pub const REFRESH_TOKEN_DURATION_DAYS: i64 = 21; +pub const REFRESH_TOKEN_COOKIE_NAME: &str = "refresh_token"; + +pub async fn generate_refresh_token( + conn: impl SqliteExecutor<'_>, + ip_address: IpAddr, + user_id: &UserID, + cookie_jar: &CookieJar<'_>, +) -> Result<(), String> { + // Generate refresh token + let refresh_token = task::spawn_blocking(|| SecretString::new(64)) + .await + .map_err(|e| e.to_string())?; + + // Insert refresh token in database + RefreshToken::insert( + conn, + refresh_token.as_ref(), + ip_address.to_string(), + user_id, + REFRESH_TOKEN_DURATION_DAYS, + ) + .await + .map_err(|e| e.to_string())?; + + // Add refresh token as a cookie + let mut cookie = Cookie::new( + REFRESH_TOKEN_COOKIE_NAME, + refresh_token.as_ref().to_string(), + ); + cookie.set_secure(true); + cookie.set_http_only(true); + cookie.set_same_site(SameSite::Strict); + cookie.set_max_age(Duration::days(REFRESH_TOKEN_DURATION_DAYS)); + cookie_jar.add(cookie); + + Ok(()) +} + +pub const JWT_DURATION_MINUTES: i64 = 15; +pub const JWT_COOKIE_NAME: &str = "access_token"; + +pub async fn generate_jwt( + conn: impl SqliteExecutor<'_>, + private_key: &PrivateKey, + issuer: &str, + audience: &str, + user: &User, + cookie_jar: &CookieJar<'_>, +) -> Result<(), String> { + // TODO: get user roles + let roles = vec![]; + + // Create jwt, sign and serialize + let jwt = JwtClaims::new(issuer, audience, user, roles) + .sign_serialize(private_key, JWT_DURATION_MINUTES) + .map_err(|e| e.to_string())?; + + // Add jwt as a cookie + let mut cookie = Cookie::new(JWT_COOKIE_NAME, jwt); + cookie.set_secure(true); + cookie.set_http_only(true); + cookie.set_same_site(SameSite::Strict); + cookie.set_max_age(Duration::minutes(JWT_DURATION_MINUTES)); + cookie_jar.add(cookie); + + Ok(()) +} diff --git a/crates/jwt/src/database.rs b/crates/jwt/src/database.rs index 9f62583..c7516bb 100644 --- a/crates/jwt/src/database.rs +++ b/crates/jwt/src/database.rs @@ -19,6 +19,13 @@ pub async fn save_new_keys( .await?) } +pub async fn revoke_all_except_one( + conn: impl SqliteExecutor<'_>, + exception: &KeyID, +) -> Result, Error> { + Ok(DatabaseKeys::revoke_all_except_one(conn, &exception.0).await?) +} + #[derive(Debug)] pub struct Key { id: KeyID, diff --git a/crates/refresh_tokens/src/database.rs b/crates/refresh_tokens/src/database.rs index b94cc85..82686bd 100644 --- a/crates/refresh_tokens/src/database.rs +++ b/crates/refresh_tokens/src/database.rs @@ -55,6 +55,10 @@ impl RefreshToken { Ok(DatabaseRefreshTokens::revoke(conn, &self.token).await?) } + pub async fn revoke_all(conn: impl SqliteExecutor<'_>) -> Result, Error> { + Ok(DatabaseRefreshTokens::revoke_all(conn).await?) + } + pub async fn revoke_all_for_user( conn: impl SqliteExecutor<'_>, user: &UserID,