diff --git a/crates/database/queries/refresh_tokens/use_token.sql b/crates/database/queries/refresh_tokens/use_token.sql new file mode 100644 index 0000000..fd70ac1 --- /dev/null +++ b/crates/database/queries/refresh_tokens/use_token.sql @@ -0,0 +1,5 @@ +update refresh_tokens + +set used_at = CURRENT_TIMESTAMP + +where token is ? \ No newline at end of file diff --git a/crates/database/queries/users/get_one_from_refresh_token.sql b/crates/database/queries/users/get_one_from_refresh_token.sql new file mode 100644 index 0000000..1848be8 --- /dev/null +++ b/crates/database/queries/users/get_one_from_refresh_token.sql @@ -0,0 +1,16 @@ +select u.id, + u.created_at as "created_at: DateTime", + u.updated_at as "updated_at: DateTime", + u.is_admin as "is_admin: bool", + u.username, + u.name, + u.email, + u.password, + u.password_recover, + u.paper_key, + u.is_archived as "is_archived: bool" +from users u + + inner join refresh_tokens rt on u.id = rt.user + +where rt.token is ? diff --git a/crates/database/sqlx-data.json b/crates/database/sqlx-data.json index 1155a09..344e2e0 100644 --- a/crates/database/sqlx-data.json +++ b/crates/database/sqlx-data.json @@ -20,6 +20,94 @@ }, "query": "update settings\n\nset business_name = ?\n\nwhere id is 0\n" }, + "3c8e31ffa5cbfd4dded8a272777cb320fb51fd2e53ed25054d24e9801df0c358": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "update refresh_tokens\n\nset used_at = CURRENT_TIMESTAMP\n\nwhere token is ?" + }, + "4f83a1908a1980ce4bf65eadf24eed2af6c6225972ef7f9f4cf0c702264033a7": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "updated_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "is_admin: bool", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "username", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "password", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "password_recover", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "paper_key", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "is_archived: bool", + "ordinal": 10, + "type_info": "Int64" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select u.id,\n u.created_at as \"created_at: DateTime\",\n u.updated_at as \"updated_at: DateTime\",\n u.is_admin as \"is_admin: bool\",\n u.username,\n u.name,\n u.email,\n u.password,\n u.password_recover,\n u.paper_key,\n u.is_archived as \"is_archived: bool\"\nfrom users u\n\n inner join refresh_tokens rt on u.id = rt.user\n\nwhere rt.token is ?\n" + }, "520fe30e21f6b6c4d9a47c457675eebd144cf020e9230d154e9e4d0c8d6e01ca": { "describe": { "columns": [], diff --git a/crates/database/src/tables/refresh_tokens.rs b/crates/database/src/tables/refresh_tokens.rs index 8b76101..c17517e 100644 --- a/crates/database/src/tables/refresh_tokens.rs +++ b/crates/database/src/tables/refresh_tokens.rs @@ -71,4 +71,17 @@ impl RefreshTokens { Ok((query.rows_affected() >= 1).then_some(())) } + + pub async fn use_token( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/refresh_tokens/use_token.sql", token) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } } diff --git a/crates/database/src/tables/users.rs b/crates/database/src/tables/users.rs index cc334e6..2b004af 100644 --- a/crates/database/src/tables/users.rs +++ b/crates/database/src/tables/users.rs @@ -85,4 +85,14 @@ impl Users { .await .map_err(handle_error) } + + pub async fn get_one_from_refresh_token( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + sqlx::query_file_as!(Self, "queries/users/get_one_from_refresh_token.sql", token) + .fetch_optional(conn) + .await + .map_err(handle_error) + } } diff --git a/crates/ezidam/src/guards/jwt.rs b/crates/ezidam/src/guards/jwt.rs index 12fb4d8..1102161 100644 --- a/crates/ezidam/src/guards/jwt.rs +++ b/crates/ezidam/src/guards/jwt.rs @@ -9,6 +9,7 @@ use rocket::Request; mod admin; mod user; +use crate::guards::refresh_token::get_refresh_token_from_cookie; pub use admin::JwtAdmin; use id::KeyID; pub use user::JwtUser; @@ -16,6 +17,26 @@ pub use user::JwtUser; #[derive(Debug)] pub enum Error { GetDatabase, + GetIpAddress, + GetCookies, + StartTransaction, + GetRefreshToken(refresh_tokens::Error), + RefreshTokenNotFound, + UserNotFound, + GetUser(users::Error), + RevokeRefreshTokens(refresh_tokens::Error), + UsedRefreshToken, + RevokedRefreshToken, + ExpiredRefreshToken, + MarkRefreshTokenUsed(refresh_tokens::Error), + GetSettings(settings::Error), + ServerUrlNotSet, + SaveRefreshToken(refresh_tokens::Error), + GetKey(jwt::Error), + MostRecentKeyNotFound, + MostRecentKeyRevoked, + SignJwt(jwt::Error), + CommitTransaction, Keys(jwt::Error), JwtParsing(jwt::Error), NoSigningKey, @@ -26,22 +47,18 @@ pub enum Error { BlockingTask(String), } -pub async fn get_jwt( +pub(super) fn get_access_token_from_cookie(request: &Request) -> Option { + request + .cookies() + .get("access_token") + .map(|cookie| cookie.value().to_string()) +} + +pub async fn validate_jwt( + jwt: String, request: &Request<'_>, get_admin: Option, ) -> Result, Outcome> { - // Get jwt - let jwt = match request - .cookies() - .get("access_token") - .map(|cookie| cookie.value()) - { - Some(jwt) => jwt, - None => { - return Err(Outcome::Forward(())); - } - }; - // Get database let db = match request.guard::<&Database>().await { Outcome::Success(database) => database, @@ -60,7 +77,6 @@ pub async fn get_jwt( } }; - let jwt = jwt.to_string(); match task::spawn_blocking(move || -> Result, Error> { // Parse jwt let parsed_jwt = jwt::parse(&jwt).map_err(Error::JwtParsing)?; @@ -124,3 +140,257 @@ pub async fn get_jwt( } } } + +pub async fn use_refresh_token( + refresh: String, + request: &Request<'_>, + get_admin: Option, +) -> Outcome { + use hash::SecretString; + use refresh_tokens::RefreshToken; + use rocket::http::{Cookie, CookieJar, SameSite}; + use rocket::time::Duration; + use rocket_client_addr::ClientRealAddr; + use settings::Settings; + use users::User; + + // Get database + let db = match request.guard::<&Database>().await { + Outcome::Success(database) => database, + Outcome::Failure(e) => return Outcome::Failure((e.0, Error::GetDatabase)), + Outcome::Forward(f) => return Outcome::Forward(f), + }; + + // Get ip address + let ip_address = match request.guard::<&ClientRealAddr>().await { + Outcome::Success(ip_address) => ip_address, + Outcome::Failure(e) => return Outcome::Failure((e.0, Error::GetIpAddress)), + Outcome::Forward(f) => return Outcome::Forward(f), + }; + + // Get cookies + let cookie_jar = match request.guard::<&CookieJar>().await { + Outcome::Success(cookie_jar) => cookie_jar, + Outcome::Failure(e) => return Outcome::Failure((e.0, Error::GetCookies)), + Outcome::Forward(f) => return Outcome::Forward(f), + }; + + let mut transaction = match db.begin().await { + Ok(transaction) => transaction, + Err(_e) => { + return Outcome::Failure((Status::InternalServerError, Error::StartTransaction)); + } + }; + + let refresh_token = match RefreshToken::get_one(&mut transaction, &refresh).await { + Ok(refresh_token) => match refresh_token { + Some(refresh_token) => refresh_token, + None => { + return Outcome::Failure(( + Status::InternalServerError, + Error::RefreshTokenNotFound, + )); + } + }, + Err(e) => { + return Outcome::Failure((Status::InternalServerError, Error::GetRefreshToken(e))); + } + }; + + let user = match User::get_one_from_refresh_token(&mut transaction, &refresh).await { + Ok(user) => match user { + Some(user) => user, + None => { + return Outcome::Failure((Status::InternalServerError, Error::UserNotFound)); + } + }, + Err(e) => { + return Outcome::Failure((Status::InternalServerError, Error::GetUser(e))); + } + }; + + // make sure that `get_admin` is respected, dont generate token for unwanted users! + if let Some(get_admin) = get_admin { + if user.is_admin() != get_admin { + return Outcome::Forward(()); + } + } + + if refresh_token.has_been_used() { + // Revoke all tokens for user + if let Err(e) = + RefreshToken::revoke_all_for_user(&mut transaction, refresh_token.user()).await + { + return Outcome::Failure((Status::InternalServerError, Error::RevokeRefreshTokens(e))); + } + + if let Err(_e) = transaction.commit().await { + return Outcome::Failure((Status::InternalServerError, Error::CommitTransaction)); + } + + return Outcome::Failure((Status::InternalServerError, Error::UsedRefreshToken)); + } + + if refresh_token.is_revoked() { + return Outcome::Failure((Status::InternalServerError, Error::RevokedRefreshToken)); + } + + if refresh_token.has_expired() { + return Outcome::Failure((Status::InternalServerError, Error::ExpiredRefreshToken)); + } + + if let Err(e) = refresh_token.use_token(&mut transaction).await { + return Outcome::Failure((Status::InternalServerError, Error::MarkRefreshTokenUsed(e))); + } + + // Get base url + let settings = match Settings::get(&mut transaction).await { + Ok(settings) => settings, + Err(e) => { + return Outcome::Failure((Status::InternalServerError, Error::GetSettings(e))); + } + }; + + let home_page = match settings.url().map(String::from) { + Some(home_page) => home_page, + None => { + return Outcome::Failure((Status::InternalServerError, Error::ServerUrlNotSet)); + } + }; + + // Generate refresh token + let new_refresh_token = match task::spawn_blocking(|| SecretString::new(64)).await { + Ok(new_refresh_token) => new_refresh_token, + Err(e) => { + return Outcome::Failure(( + Status::InternalServerError, + Error::BlockingTask(e.to_string()), + )); + } + }; + + // Refresh token duration in days + let refresh_token_duration = 21; + + // Insert refresh token in database + if let Err(e) = RefreshToken::insert( + &mut transaction, + new_refresh_token.as_ref(), + ip_address.get_ipv6_string().as_str(), + user.id(), + refresh_token_duration, + ) + .await + { + return Outcome::Failure((Status::InternalServerError, Error::SaveRefreshToken(e))); + } + + // Add refresh token as a cookie + let mut cookie = Cookie::new("refresh_token", 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_jar.add(cookie); + + // Get latest key from database + let key = match Key::get_most_recent(&mut transaction).await { + Ok(key) => match key { + Some(key) => key, + None => { + return Outcome::Failure(( + Status::InternalServerError, + Error::MostRecentKeyNotFound, + )); + } + }, + Err(e) => { + return Outcome::Failure((Status::InternalServerError, Error::GetKey(e))); + } + }; + + // Make sure key has not been revoked + if key.is_revoked() { + return Outcome::Failure((Status::InternalServerError, Error::MostRecentKeyRevoked)); + } + + // Import private key + let private_key = + match task::spawn_blocking(move || PrivateKey::from_der(key.private_der(), key.key_id())) + .await + { + Ok(private_key) => match private_key { + Ok(private_key) => private_key, + Err(e) => { + return Outcome::Failure((Status::InternalServerError, Error::ImportKey(e))); + } + }, + Err(e) => { + return Outcome::Failure(( + Status::InternalServerError, + Error::BlockingTask(e.to_string()), + )); + } + }; + + // 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) + { + Ok(jwt) => jwt, + Err(e) => { + return Outcome::Failure((Status::InternalServerError, Error::SignJwt(e))); + } + }; + + // 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); + + if let Err(_e) = transaction.commit().await { + return Outcome::Failure((Status::InternalServerError, Error::CommitTransaction)); + } + + Outcome::Success(jwt_claims) +} + +pub async fn use_access_token_or_refresh_token( + request: &Request<'_>, + get_admin: Option, +) -> Outcome { + let access_token = get_access_token_from_cookie(request); + let refresh_token = get_refresh_token_from_cookie(request); + + match (access_token, refresh_token) { + (Some(access), _) => { + // If there is an access token, attempt to validate it + match validate_jwt(access, request, get_admin).await { + Ok(jwt_claims) => match jwt_claims { + Some(jwt_claims) => Outcome::Success(jwt_claims), + None => Outcome::Forward(()), + }, + Err(e) => e, + } + } + (None, Some(refresh)) => { + // Use refresh token to get new access token + use_refresh_token(refresh, request, get_admin).await + } + (None, None) => { + // Nothing to do + Outcome::Forward(()) + } + } +} diff --git a/crates/ezidam/src/guards/jwt/admin.rs b/crates/ezidam/src/guards/jwt/admin.rs index c82ae08..d5e34c9 100644 --- a/crates/ezidam/src/guards/jwt/admin.rs +++ b/crates/ezidam/src/guards/jwt/admin.rs @@ -1,3 +1,5 @@ +use super::use_access_token_or_refresh_token; +use super::Error; use jwt::JwtClaims; use rocket::request::{FromRequest, Outcome}; use rocket::Request; @@ -6,15 +8,13 @@ pub struct JwtAdmin(pub JwtClaims); #[rocket::async_trait] impl<'r> FromRequest<'r> for JwtAdmin { - type Error = super::Error; + type Error = Error; async fn from_request(request: &'r Request<'_>) -> Outcome { - match super::get_jwt(request, Some(true)).await { - Ok(jwt_claims) => match jwt_claims { - Some(jwt_claims) => Outcome::Success(JwtAdmin(jwt_claims)), - None => Outcome::Forward(()), - }, - Err(e) => return e, - } + let get_admin: Option = Some(true); + + use_access_token_or_refresh_token(request, get_admin) + .await + .map(Self) } } diff --git a/crates/ezidam/src/guards/jwt/user.rs b/crates/ezidam/src/guards/jwt/user.rs index e5b7281..685473a 100644 --- a/crates/ezidam/src/guards/jwt/user.rs +++ b/crates/ezidam/src/guards/jwt/user.rs @@ -1,3 +1,4 @@ +use super::use_access_token_or_refresh_token; use jwt::JwtClaims; use rocket::request::{FromRequest, Outcome}; use rocket::Request; @@ -9,12 +10,10 @@ impl<'r> FromRequest<'r> for JwtUser { type Error = super::Error; async fn from_request(request: &'r Request<'_>) -> Outcome { - match super::get_jwt(request, None).await { - Ok(jwt_claims) => match jwt_claims { - Some(jwt_claims) => Outcome::Success(JwtUser(jwt_claims)), - None => Outcome::Forward(()), - }, - Err(e) => return e, - } + let get_admin: Option = None; + + use_access_token_or_refresh_token(request, get_admin) + .await + .map(Self) } } diff --git a/crates/ezidam/src/guards/refresh_token.rs b/crates/ezidam/src/guards/refresh_token.rs index b1153dd..4ab6f00 100644 --- a/crates/ezidam/src/guards/refresh_token.rs +++ b/crates/ezidam/src/guards/refresh_token.rs @@ -3,21 +3,28 @@ use rocket::Request; pub struct RefreshToken(pub String); +pub fn get_refresh_token_from_cookie(request: &Request) -> Option { + match request.cookies().get("refresh_token") { + Some(cookie) => { + let value = cookie.value(); + + if value.len() == 64 { + Some(value.to_string()) + } else { + None + } + } + None => None, + } +} + #[rocket::async_trait] impl<'r> FromRequest<'r> for RefreshToken { type Error = std::convert::Infallible; async fn from_request(request: &'r Request<'_>) -> Outcome { - match request.cookies().get("refresh_token") { - Some(cookie) => { - let value = cookie.value(); - - if value.len() == 64 { - Outcome::Success(Self(value.to_string())) - } else { - Outcome::Forward(()) - } - } + match get_refresh_token_from_cookie(request) { + Some(refresh_token) => Outcome::Success(Self(refresh_token)), None => Outcome::Forward(()), } } diff --git a/crates/ezidam/src/routes/root.rs b/crates/ezidam/src/routes/root.rs index b848a98..7d308f1 100644 --- a/crates/ezidam/src/routes/root.rs +++ b/crates/ezidam/src/routes/root.rs @@ -12,7 +12,7 @@ pub fn routes() -> Vec { homepage_user, homepage_redirect, redirect_to_setup, - logout + logout, ] } @@ -57,7 +57,7 @@ async fn avatar( size: Option, ) -> Result { // Verify existence of user - let _user = User::get_by_login(&mut *db, user_id.0.as_ref()) + User::get_by_login(&mut *db, user_id.0.as_ref()) .await? .ok_or_else(|| Error::not_found(user_id.0.to_string()))?; diff --git a/crates/refresh_tokens/src/database.rs b/crates/refresh_tokens/src/database.rs index 38767bb..485ec30 100644 --- a/crates/refresh_tokens/src/database.rs +++ b/crates/refresh_tokens/src/database.rs @@ -61,4 +61,8 @@ impl RefreshToken { ) -> Result, Error> { Ok(DatabaseRefreshTokens::revoke_all_for_user(conn, user.as_ref()).await?) } + + pub async fn use_token(&self, conn: impl SqliteExecutor<'_>) -> Result, Error> { + Ok(DatabaseRefreshTokens::use_token(conn, &self.token).await?) + } } diff --git a/crates/users/src/database.rs b/crates/users/src/database.rs index 929573a..1a96d42 100644 --- a/crates/users/src/database.rs +++ b/crates/users/src/database.rs @@ -96,4 +96,13 @@ impl User { .await? .map(Self::from)) } + + pub async fn get_one_from_refresh_token( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + Ok(DatabaseUsers::get_one_from_refresh_token(conn, token) + .await? + .map(Self::from)) + } }