diff --git a/Cargo.lock b/Cargo.lock index c99c81e15..0655f1c26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,6 +335,7 @@ dependencies = [ "dotenv", "gotrue", "gotrue-entity", + "jwt", "redis", "reqwest", "serde", @@ -492,7 +493,6 @@ dependencies = [ "gotrue-entity", "infra", "itertools", - "jsonwebtoken", "lazy_static", "mime", "once_cell", @@ -1911,6 +1911,8 @@ name = "gotrue-entity" version = "0.1.0" dependencies = [ "anyhow", + "jsonwebtoken", + "lazy_static", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index bfbebe399..7fbe9b680 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,6 @@ fancy-regex = "0.11.0" validator = "0.16.0" bytes = "1.4.0" rcgen = { version = "0.10.0", features = ["pem", "x509-parser"] } -jsonwebtoken = "8.3.0" mime = "0.3.17" # aws-config = "0.56.1" # aws-sdk-s3 = "0.31.1" diff --git a/admin_frontend/Cargo.toml b/admin_frontend/Cargo.toml index cc8a44c3a..04fd72426 100644 --- a/admin_frontend/Cargo.toml +++ b/admin_frontend/Cargo.toml @@ -26,3 +26,4 @@ tower-http = { version = "0.4.4", features = ["cors"] } tower = "0.4.13" tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +jwt = "0.16.0" diff --git a/admin_frontend/src/models.rs b/admin_frontend/src/models.rs index 87ae37437..5c7ae5d47 100644 --- a/admin_frontend/src/models.rs +++ b/admin_frontend/src/models.rs @@ -6,11 +6,6 @@ pub struct LoginRequest { pub password: String, } -#[derive(Deserialize)] -pub struct AddUserRequest { - pub email: String, -} - #[derive(Serialize)] pub struct LoginResponse { pub access_token: String, diff --git a/admin_frontend/src/session.rs b/admin_frontend/src/session.rs index 37aef1123..2557af8e1 100644 --- a/admin_frontend/src/session.rs +++ b/admin_frontend/src/session.rs @@ -1,3 +1,5 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + use axum::{ async_trait, extract::FromRequestParts, @@ -5,6 +7,8 @@ use axum::{ response::{IntoResponse, Redirect}, }; use axum_extra::extract::CookieJar; +use gotrue::grant::{Grant, RefreshTokenGrant}; +use jwt::{Claims, Header}; use redis::{aio::ConnectionManager, AsyncCommands, FromRedisValue, ToRedisArgs}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -38,7 +42,7 @@ impl SessionStorage { } } - pub async fn put_user_session(&self, user_session: UserSession) -> redis::RedisResult<()> { + pub async fn put_user_session(&self, user_session: &UserSession) -> redis::RedisResult<()> { let key = session_id_key(&user_session.session_id); self .redis_client @@ -93,21 +97,70 @@ impl FromRequestParts for UserSession { .ok_or(SessionRejection::NoSessionId)? .value(); - let session = state + let mut session = state .session_store .get_user_session(session_id) .await .ok_or(SessionRejection::SessionNotFound)?; + if has_expired(session.access_token.as_str()) { + // Get new pair of access token and refresh token + let refresh_token = session.refresh_token; + let new_token = state + .gotrue_client + .clone() + .token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token })) + .await + .map_err(|err| SessionRejection::RefreshTokenError(err.to_string()))?; + + session.access_token = new_token.access_token; + session.refresh_token = new_token.refresh_token; + + // Update session in redis + let _ = state + .session_store + .put_user_session(&session) + .await + .map_err(|err| { + tracing::error!("failed to update session in redis: {}", err); + }); + } + Ok(session) } } +fn has_expired(access_token: &str) -> bool { + match get_session_expiration(access_token) { + Some(expiration_seconds) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + now > expiration_seconds + }, + None => false, + } +} + +fn get_session_expiration(access_token: &str) -> Option { + // no need to verify, let the appflowy cloud server do it + // in that way, frontend server does not need to know the secret + match jwt::Token::::parse_unverified(access_token) { + Ok(unverified) => unverified.claims().registered.expiration, + Err(e) => { + tracing::error!("failed to parse unverified token: {}", e); + None + }, + } +} + #[derive(Clone, Debug)] pub enum SessionRejection { NoSessionId, SessionNotFound, CookieError(String), + RefreshTokenError(String), } impl IntoResponse for SessionRejection { @@ -119,6 +172,10 @@ impl IntoResponse for SessionRejection { Redirect::temporary("/web/login").into_response() }, SessionRejection::SessionNotFound => Redirect::temporary("/web/login").into_response(), + SessionRejection::RefreshTokenError(err) => { + tracing::warn!("refresh token error: {}", err); + Redirect::temporary("/web/login").into_response() + }, } } } diff --git a/admin_frontend/src/web_api.rs b/admin_frontend/src/web_api.rs index 50c49c456..361df18e2 100644 --- a/admin_frontend/src/web_api.rs +++ b/admin_frontend/src/web_api.rs @@ -1,15 +1,15 @@ use crate::error::WebApiError; -use crate::models::AddUserRequest; use crate::response::WebApiResponse; use crate::session::{self, UserSession}; use crate::{models::LoginRequest, AppState}; +use axum::extract::Path; use axum::http::status; use axum::response::Result; use axum::Json; use axum::{extract::State, routing::post, Router}; use axum_extra::extract::cookie::Cookie; use axum_extra::extract::CookieJar; -use gotrue::params::AdminUserParams; +use gotrue::params::{AdminDeleteUserParams, AdminUserParams}; use gotrue_entity::User; pub fn router() -> Router { @@ -17,16 +17,34 @@ pub fn router() -> Router { // TODO .route("/login", post(login_handler)) .route("/logout", post(logout_handler)) - .route("/add_user", post(add_user_handler)) + .route("/user/:param", post(post_user_handler).delete(delete_user_handler)) } -pub async fn add_user_handler( +pub async fn delete_user_handler( State(state): State, session: UserSession, - Json(param): Json, + Path(user_uuid): Path, +) -> Result, WebApiError<'static>> { + state + .gotrue_client + .admin_delete_user( + &session.access_token, + &user_uuid, + &AdminDeleteUserParams { + should_soft_delete: true, + }, + ) + .await?; + Ok(().into()) +} + +pub async fn post_user_handler( + State(state): State, + session: UserSession, + Path(email): Path, ) -> Result, WebApiError<'static>> { let add_user_params = AdminUserParams { - email: param.email.to_owned(), + email, ..Default::default() }; let user = state @@ -59,7 +77,7 @@ pub async fn login_handler( token.access_token.to_string(), token.refresh_token.to_owned(), ); - state.session_store.put_user_session(new_session).await?; + state.session_store.put_user_session(&new_session).await?; let mut cookie = Cookie::new("session_id", new_session_id.to_string()); cookie.set_path("/"); diff --git a/admin_frontend/templates/users.html b/admin_frontend/templates/users.html index a54a0261c..a08b960ab 100644 --- a/admin_frontend/templates/users.html +++ b/admin_frontend/templates/users.html @@ -29,14 +29,8 @@

User List

// Get the email from the user const email = prompt('Please enter the new user email:'); if (email) { - fetch("/web-api/add_user", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: email, - }), + fetch(`/web-api/user/${email}`, { + method: "POST" }) .then((response) => { if (!response.ok) { diff --git a/libs/gotrue-entity/Cargo.toml b/libs/gotrue-entity/Cargo.toml index d7c72dbaa..2c4e28eaa 100644 --- a/libs/gotrue-entity/Cargo.toml +++ b/libs/gotrue-entity/Cargo.toml @@ -10,3 +10,5 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.105" anyhow = "1.0.75" reqwest = "0.11.20" +lazy_static = "1.4.0" +jsonwebtoken = "8.3.0" diff --git a/libs/gotrue-entity/src/gotrue_jwt.rs b/libs/gotrue-entity/src/gotrue_jwt.rs new file mode 100644 index 000000000..192c80eb5 --- /dev/null +++ b/libs/gotrue-entity/src/gotrue_jwt.rs @@ -0,0 +1,40 @@ +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct GoTrueJWTClaims { + // JWT standard claims + pub aud: Option, + pub exp: Option, + pub jti: Option, + pub iat: Option, + pub iss: Option, + pub nbf: Option, + pub sub: Option, + + pub email: String, + pub phone: String, + pub app_metadata: serde_json::Value, + pub user_metadata: serde_json::Value, + pub role: String, + pub aal: Option, + pub amr: Option>, + pub session_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Amr { + pub method: String, + pub timestamp: u64, + pub provider: Option, +} + +lazy_static::lazy_static! { + pub static ref VALIDATION: Validation = Validation::new(Algorithm::HS256); +} + +impl GoTrueJWTClaims { + pub fn verify(token: &str, secret: &[u8]) -> Result { + Ok(decode(token, &DecodingKey::from_secret(secret), &VALIDATION)?.claims) + } +} diff --git a/libs/gotrue-entity/src/lib.rs b/libs/gotrue-entity/src/lib.rs index f76ee77ea..d8e6974e7 100644 --- a/libs/gotrue-entity/src/lib.rs +++ b/libs/gotrue-entity/src/lib.rs @@ -1,3 +1,5 @@ +pub mod gotrue_jwt; + use serde::{Deserialize, Serialize}; use std::fmt::Formatter; use std::{collections::BTreeMap, fmt::Display}; diff --git a/libs/gotrue/src/api.rs b/libs/gotrue/src/api.rs index f0d29a675..f39dcefa2 100644 --- a/libs/gotrue/src/api.rs +++ b/libs/gotrue/src/api.rs @@ -1,4 +1,6 @@ -use crate::params::{AdminUserParams, GenerateLinkParams, GenerateLinkResponse}; +use crate::params::{ + AdminDeleteUserParams, AdminUserParams, GenerateLinkParams, GenerateLinkResponse, +}; use anyhow::Context; use super::grant::Grant; @@ -145,6 +147,22 @@ impl Client { to_gotrue_result(resp).await } + pub async fn admin_delete_user( + &self, + access_token: &str, + user_uuid: &str, + delete_user_params: &AdminDeleteUserParams, + ) -> Result<(), GoTrueError> { + let resp = self + .client + .delete(format!("{}/admin/users/{}", self.base_url, user_uuid)) + .header("Authorization", format!("Bearer {}", access_token)) + .json(&delete_user_params) + .send() + .await?; + check_gotrue_result(resp).await + } + pub async fn admin_add_user( &self, access_token: &str, @@ -188,3 +206,12 @@ where Err(err) } } + +async fn check_gotrue_result(resp: reqwest::Response) -> Result<(), GoTrueError> { + if resp.status().is_success() { + Ok(()) + } else { + let err: GoTrueError = from_body(resp).await?; + Err(err) + } +} diff --git a/libs/gotrue/src/params.rs b/libs/gotrue/src/params.rs index 2bc86b615..329297bb5 100644 --- a/libs/gotrue/src/params.rs +++ b/libs/gotrue/src/params.rs @@ -3,6 +3,11 @@ use std::collections::btree_map::BTreeMap; use gotrue_entity::{Factor, Identity}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Deserialize, Serialize)] +pub struct AdminDeleteUserParams { + pub should_soft_delete: bool, +} + #[derive(Debug, Default, Deserialize, Serialize)] pub struct AdminUserParams { pub aud: String, diff --git a/src/component/auth/jwt.rs b/src/component/auth/jwt.rs index ae3667230..4da35c224 100644 --- a/src/component/auth/jwt.rs +++ b/src/component/auth/jwt.rs @@ -1,7 +1,7 @@ use actix_http::Payload; use actix_web::{web::Data, FromRequest, HttpRequest}; -use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use gotrue_entity::gotrue_jwt::GoTrueJWTClaims; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use sqlx::types::{uuid, Uuid}; @@ -11,10 +11,6 @@ use std::str::FromStr; use crate::state::AppState; -lazy_static::lazy_static! { - pub static ref VALIDATION: Validation = Validation::new(Algorithm::HS256); -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserUuid(uuid::Uuid); @@ -151,37 +147,3 @@ fn gotrue_jwt_claims_from_token( ) .map_err(actix_web::error::ErrorUnauthorized) } - -#[derive(Debug, Serialize, Deserialize)] -pub struct GoTrueJWTClaims { - // JWT standard claims - pub aud: Option, - pub exp: Option, - pub jti: Option, - pub iat: Option, - pub iss: Option, - pub nbf: Option, - pub sub: Option, - - pub email: String, - pub phone: String, - pub app_metadata: serde_json::Value, - pub user_metadata: serde_json::Value, - pub role: String, - pub aal: Option, - pub amr: Option>, - pub session_id: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Amr { - pub method: String, - pub timestamp: u64, - pub provider: Option, -} - -impl GoTrueJWTClaims { - pub fn verify(token: &str, secret: &[u8]) -> Result { - Ok(decode(token, &DecodingKey::from_secret(secret), &VALIDATION)?.claims) - } -} diff --git a/tests/gotrue/admin.rs b/tests/gotrue/admin.rs index f3d8a59e7..9ecf1109a 100644 --- a/tests/gotrue/admin.rs +++ b/tests/gotrue/admin.rs @@ -2,7 +2,7 @@ use client_api::extract_sign_in_url; use gotrue::{ api::Client, grant::{Grant, PasswordGrant}, - params::{AdminUserParams, GenerateLinkParams}, + params::{AdminDeleteUserParams, AdminUserParams, GenerateLinkParams}, }; use crate::{ @@ -12,7 +12,7 @@ use crate::{ }; #[tokio::test] -async fn admin_user_create_and_list() { +async fn admin_user_create_and_list_delete() { let http_client = reqwest::Client::new(); let gotrue_client = Client::new(http_client, "http://localhost:9998"); let admin_token = gotrue_client @@ -54,8 +54,34 @@ async fn admin_user_create_and_list() { let users = gotrue_client .admin_list_user(&admin_token.access_token) .await + .unwrap() + .users; + + // should be able to find user that was just created + let new_user = users.iter().find(|u| u.email == user_email).unwrap(); + + // delete user that was just created + let _ = gotrue_client + .admin_delete_user( + &admin_token.access_token, + &new_user.id, + &AdminDeleteUserParams { + should_soft_delete: true, + }, + ) + .await .unwrap(); - assert!(users.users.len() > 2); + + let users = gotrue_client + .admin_list_user(&admin_token.access_token) + .await + .unwrap() + .users; + + // user list should not contain the new user added + // since it's deleted + let found = users.iter().any(|u| u.email == user_email); + assert!(!found); } #[tokio::test]