Merge pull request #780 from AppFlowy-IO/feat/delete-user

Feat/delete user
This commit is contained in:
Zack 2024-09-03 14:10:09 +08:00 committed by GitHub
commit 8015e34841
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 354 additions and 42 deletions

View file

@ -278,3 +278,18 @@ pub async fn verify_token_cloud(
let _: SignInTokenResponse = from_json_response(resp).await?;
Ok(())
}
pub async fn delete_current_user(
access_token: &str,
appflowy_cloud_base_url: &str,
) -> Result<(), Error> {
let http_client = reqwest::Client::new();
let url = format!("{}/api/user", appflowy_cloud_base_url);
let resp = http_client
.delete(url)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await?;
check_response(resp).await?;
Ok(())
}

View file

@ -27,13 +27,6 @@ pub struct WorkspaceMember {
pub role: String,
}
#[derive(Deserialize)]
pub struct WorkspaceUsageLimit {
pub total_blob_size: i64,
pub single_blob_size: i64,
pub member_count: i64,
}
#[derive(Deserialize, Serialize)]
pub struct WorkspaceBlobUsage {
pub consumed_capacity: u64,

View file

@ -1,13 +1,6 @@
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use shared_entity::response::AppResponseError;
#[derive(Deserialize, Debug)]
pub struct AppFlowyCloudError {
pub code: String,
pub message: String,
}
#[derive(Debug)]
pub enum Error {
NotOk(u16, String), // HTTP status code, payload

View file

@ -1,6 +1,7 @@
use crate::error::WebApiError;
use crate::ext::api::{
accept_workspace_invitation, invite_user_to_workspace, leave_workspace, verify_token_cloud,
accept_workspace_invitation, delete_current_user, invite_user_to_workspace, leave_workspace,
verify_token_cloud,
};
use crate::models::{
WebApiAdminCreateUserRequest, WebApiChangePasswordRequest, WebApiCreateSSOProviderRequest,
@ -39,6 +40,7 @@ pub fn router() -> Router<AppState> {
.route("/workspace/:workspace_id/leave", post(leave_workspace_handler))
.route("/invite/:invite_id/accept", post(invite_accept_handler))
.route("/open_app", post(open_app_handler))
.route("/delete-account", delete(delete_account_handler))
// admin
.route("/admin/user", post(admin_add_user_handler))
@ -115,6 +117,15 @@ async fn open_app_handler(session: UserSession) -> Result<HeaderMap, WebApiError
Ok(htmx_redirect(&app_sign_in_url))
}
/// Delete the user account and all associated data.
async fn delete_account_handler(
state: State<AppState>,
session: UserSession,
) -> Result<HeaderMap, WebApiError<'static>> {
delete_current_user(&session.token.access_token, &state.appflowy_cloud_url).await?;
Ok(htmx_redirect("/web/login"))
}
// Invite another user, this will trigger email sending
// to the target user
async fn invite_handler(

View file

@ -10,6 +10,13 @@
>
{{ user.email|escape }}
</div>
<div
hx-delete="/web-api/delete-account"
hx-confirm="This will erase all data associated with this account. Are you sure?"
class="button red"
>
Delete Account
</div>
</div>
<div id="top-menu-bar-right">

View file

@ -69,6 +69,11 @@ GOTRUE_EXTERNAL_DISCORD_ENABLED=false
GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=
GOTRUE_EXTERNAL_DISCORD_SECRET=
GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=http://localhost:9999/callback
# Apple OAuth2
GOTRUE_EXTERNAL_APPLE_ENABLED=false
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=
GOTRUE_EXTERNAL_APPLE_SECRET=
GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=http://localhost:9999/callback
# File Storage
APPFLOWY_S3_USE_MINIO=true

View file

@ -317,6 +317,7 @@ pub enum ErrorCode {
SqlxArgEncodingError = 1035,
InvalidContentType = 1036,
SingleUploadLimitExceeded = 1037,
AppleRevokeTokenError = 1038,
}
impl ErrorCode {

View file

@ -1,5 +1,6 @@
use crate::notify::{ClientToken, TokenStateReceiver};
use app_error::AppError;
use client_api_entity::auth_dto::DeleteUserQuery;
use client_api_entity::workspace_dto::FolderView;
use client_api_entity::workspace_dto::QueryWorkspaceFolder;
use client_api_entity::workspace_dto::QueryWorkspaceParam;
@ -274,13 +275,18 @@ impl Client {
.split('&');
let mut refresh_token: Option<&str> = None;
let mut provider_token: Option<String> = None;
let mut provider_refresh_token: Option<String> = None;
for param in key_value_pairs {
match param.split_once('=') {
Some(pair) => {
let (k, v) = pair;
if k == "refresh_token" {
refresh_token = Some(v);
break;
} else if k == "provider_token" {
provider_token = Some(v.to_string());
} else if k == "provider_refresh_token" {
provider_refresh_token = Some(v.to_string());
}
},
None => warn!("param is not in key=value format: {}", param),
@ -288,13 +294,18 @@ impl Client {
}
let refresh_token = refresh_token.ok_or(url_missing_param("refresh_token"))?;
let new_token = self
let mut new_token = self
.gotrue_client
.token(&Grant::RefreshToken(RefreshTokenGrant {
refresh_token: refresh_token.to_owned(),
}))
.await?;
// refresh endpoint does not return provider token
// so we need to set it manually to preserve this information
new_token.provider_access_token = provider_token;
new_token.provider_refresh_token = provider_refresh_token;
let (_user, new) = self.verify_token(&new_token.access_token).await?;
self.token.write().set(new_token);
Ok(new)
@ -771,6 +782,37 @@ impl Client {
AppResponse::<()>::from_response(resp).await?.into_error()
}
#[instrument(level = "info", skip_all, err)]
pub async fn delete_user(&self) -> Result<(), AppResponseError> {
let (provider_access_token, provider_refresh_token) = {
let token = self.token();
let token_read = token.read();
let token_resp = token_read
.as_ref()
.ok_or(AppResponseError::from(AppError::NotLoggedIn(
"token is empty".to_string(),
)))?;
(
token_resp.provider_access_token.clone(),
token_resp.provider_refresh_token.clone(),
)
};
let url = format!("{}/api/user", self.base_url);
let resp = self
.http_client_with_auth(Method::DELETE, &url)
.await?
.query(&DeleteUserQuery {
provider_access_token,
provider_refresh_token,
})
.send()
.await?;
log_request_id(&resp);
AppResponse::<()>::from_response(resp).await?.into_error()
}
pub async fn get_snapshot_list(
&self,
workspace_id: &str,

View file

@ -47,18 +47,26 @@ impl Action for RefreshTokenAction {
if let (Some(token), Some(gotrue_client)) =
(weak_token.upgrade(), weak_gotrue_client.upgrade())
{
let refresh_token = token
.read()
.as_ref()
.ok_or(GoTrueError::NotLoggedIn(
let (refresh_token, provider_access_token, provider_refresh_token) = {
let mut token_write = token.write();
let gotrue_resp_token = token_write.as_mut().ok_or(GoTrueError::NotLoggedIn(
"fail to refresh user token".to_owned(),
))?
.refresh_token
.as_str()
.to_owned();
let access_token_resp = gotrue_client
))?;
let refresh_token = gotrue_resp_token.refresh_token.as_str().to_owned();
let provider_access_token = gotrue_resp_token.provider_access_token.take();
let provider_refresh_token = gotrue_resp_token.provider_refresh_token.take();
(refresh_token, provider_access_token, provider_refresh_token)
};
let mut access_token_resp = gotrue_client
.token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token }))
.await?;
// refresh does not preserve provider token and refresh token
// so we need to set it manually to preserve this information
access_token_resp.provider_access_token = provider_access_token;
access_token_resp.provider_refresh_token = provider_refresh_token;
token.write().set(access_token_resp);
}
Ok(())

View file

@ -14,7 +14,7 @@ use infra::reqwest::{check_response, from_body, from_response};
use reqwest::{Method, RequestBuilder};
use tracing::event;
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct Client {
client: reqwest::Client,
pub base_url: String,

View file

@ -65,3 +65,9 @@ pub struct SignInPasswordResponse {
pub struct SignInTokenResponse {
pub is_new: bool,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct DeleteUserQuery {
pub provider_access_token: Option<String>,
pub provider_refresh_token: Option<String>,
}

View file

@ -1,3 +1,4 @@
use crate::biz::user::user_delete::delete_user;
use crate::biz::user::user_info::{get_profile, get_user_workspace_info, update_user};
use crate::biz::user::user_verify::verify_token;
use crate::state::AppState;
@ -6,7 +7,7 @@ use actix_web::Result;
use actix_web::{web, Scope};
use authentication::jwt::{Authorization, UserUuid};
use database_entity::dto::{AFUserProfile, AFUserWorkspaceInfo};
use shared_entity::dto::auth_dto::{SignInTokenResponse, UpdateUserParams};
use shared_entity::dto::auth_dto::{DeleteUserQuery, SignInTokenResponse, UpdateUserParams};
use shared_entity::response::AppResponseError;
use shared_entity::response::{AppResponse, JsonAppResponse};
@ -16,6 +17,7 @@ pub fn user_scope() -> Scope {
.service(web::resource("/update").route(web::post().to(update_user_handler)))
.service(web::resource("/profile").route(web::get().to(get_user_profile_handler)))
.service(web::resource("/workspace").route(web::get().to(get_user_workspace_info_handler)))
.service(web::resource("").route(web::delete().to(delete_user_handler)))
}
#[tracing::instrument(skip(state, path), err)]
@ -61,3 +63,29 @@ async fn update_user_handler(
update_user(&state.pg_pool, auth.uuid()?, params).await?;
Ok(AppResponse::Ok().into())
}
#[tracing::instrument(skip(state), err)]
async fn delete_user_handler(
auth: Authorization,
state: Data<AppState>,
query: web::Query<DeleteUserQuery>,
) -> Result<JsonAppResponse<()>, actix_web::Error> {
let user_uuid = auth.uuid()?;
let DeleteUserQuery {
provider_access_token,
provider_refresh_token,
} = query.into_inner();
delete_user(
&state.pg_pool,
&state.bucket_storage,
&state.gotrue_client,
&state.gotrue_admin,
&state.config.apple_oauth,
auth,
user_uuid,
provider_access_token,
provider_refresh_token,
)
.await?;
Ok(AppResponse::Ok().into())
}

View file

@ -250,10 +250,13 @@ async fn delete_workspace_handler(
workspace_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<Json<AppResponse<()>>> {
let bucket_storage = &state.bucket_storage;
// TODO: add permission for workspace deletion
workspace::ops::delete_workspace_for_user(&state.pg_pool, &workspace_id, bucket_storage).await?;
workspace::ops::delete_workspace_for_user(
state.pg_pool.clone(),
*workspace_id,
state.bucket_storage.clone(),
)
.await?;
Ok(AppResponse::Ok().into())
}

View file

@ -216,7 +216,7 @@ pub async fn init_state(config: &Config, rt_cmd_tx: CLCommandSender) -> Result<A
// Gotrue
info!("Connecting to GoTrue...");
let gotrue_client = get_gotrue_client(&config.gotrue).await?;
let gotrue_admin = setup_admin_account(&gotrue_client, &pg_pool, &config.gotrue).await?;
let gotrue_admin = setup_admin_account(gotrue_client.clone(), &pg_pool, &config.gotrue).await?;
// Redis
info!("Connecting to Redis...");
@ -320,13 +320,17 @@ pub async fn init_state(config: &Config, rt_cmd_tx: CLCommandSender) -> Result<A
}
async fn setup_admin_account(
gotrue_client: &gotrue::api::Client,
gotrue_client: gotrue::api::Client,
pg_pool: &PgPool,
gotrue_setting: &GoTrueSetting,
) -> Result<GoTrueAdmin, Error> {
let admin_email = gotrue_setting.admin_email.as_str();
let password = gotrue_setting.admin_password.expose_secret();
let gotrue_admin = GoTrueAdmin::new(admin_email.to_owned(), password.to_owned());
let gotrue_admin = GoTrueAdmin::new(
admin_email.to_owned(),
password.to_owned(),
gotrue_client.clone(),
);
match gotrue_client
.token(&Grant::Password(PasswordGrant {

View file

@ -1,3 +1,4 @@
pub mod user_delete;
pub mod user_info;
pub mod user_init;
pub mod user_verify;

152
src/biz/user/user_delete.rs Normal file
View file

@ -0,0 +1,152 @@
use std::sync::Arc;
use crate::biz::workspace::ops::get_all_user_workspaces;
use crate::state::GoTrueAdmin;
use crate::{biz::workspace::ops::delete_workspace_for_user, config::config::AppleOAuthSetting};
use app_error::ErrorCode;
use authentication::jwt::Authorization;
use database::file::s3_client_impl::S3BucketStorage;
use gotrue::params::AdminDeleteUserParams;
use secrecy::{ExposeSecret, Secret};
use shared_entity::response::AppResponseError;
use uuid::Uuid;
#[allow(clippy::too_many_arguments)]
pub async fn delete_user(
pg_pool: &sqlx::PgPool,
bucket_storage: &Arc<S3BucketStorage>,
gotrue_client: &gotrue::api::Client,
gotrue_admin: &GoTrueAdmin,
apple_oauth: &AppleOAuthSetting,
auth: Authorization,
user_uuid: Uuid,
provider_access_token: Option<String>,
provider_refresh_token: Option<String>,
) -> Result<(), AppResponseError> {
if is_apple_user(&auth) {
if let Err(err) = revoke_apple_user(
&apple_oauth.client_id,
&apple_oauth.client_secret,
provider_access_token,
provider_refresh_token,
)
.await
{
tracing::warn!("revoke apple user failed: {:?}", err);
};
}
let admin_token = gotrue_admin.token().await?;
gotrue_client
.admin_delete_user(
&admin_token,
&user_uuid.to_string(),
&AdminDeleteUserParams {
should_soft_delete: false,
},
)
.await
.map_err(AppResponseError::from)?;
// spawn tasks to delete all user workspace and object storage
let user_workspaces = get_all_user_workspaces(pg_pool, &user_uuid, false).await?;
let mut tasks = vec![];
for workspace in user_workspaces {
let cloned_pg_pool = pg_pool.clone();
tasks.push(tokio::spawn(delete_workspace_for_user(
cloned_pg_pool,
workspace.workspace_id,
bucket_storage.clone(),
)));
}
for task in tasks {
task.await??;
}
Ok(())
}
async fn revoke_apple_user(
client_id: &str,
client_secret: &Secret<String>,
apple_access_token: Option<String>,
apple_refresh_token: Option<String>,
) -> Result<(), AppResponseError> {
let (type_type_hint, token) = match apple_access_token {
Some(access_token) => ("access_token", access_token),
None => match apple_refresh_token {
Some(refresh_token) => ("refresh_token", refresh_token),
None => {
return Err(AppResponseError::new(
ErrorCode::InvalidRequest,
"apple email deletion must provide access_token or refresh_token",
))
},
},
};
if let Err(err) = revoke_apple_token_http_call(
client_id,
client_secret.expose_secret(),
&token,
type_type_hint,
)
.await
{
tracing::warn!("revoke apple token failed: {:?}", err);
};
Ok(())
}
fn is_apple_user(auth: &Authorization) -> bool {
if let Some(provider) = auth.claims.app_metadata.get("provider") {
if provider == "apple" {
return true;
}
};
if let Some(providers) = auth.claims.app_metadata.get("providers") {
if let Some(providers) = providers.as_array() {
for provider in providers {
if provider == "apple" {
return true;
}
}
}
}
false
}
/// Based on: https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens
async fn revoke_apple_token_http_call(
apple_client_id: &str,
apple_client_secret: &str,
apple_user_token: &str,
token_type_hint: &str,
) -> Result<(), AppResponseError> {
let resp = reqwest::Client::new()
.post("https://appleid.apple.com/auth/revoke")
.form(&[
("client_id", apple_client_id),
("client_secret", apple_client_secret),
("token", apple_user_token),
("token_type_hint", token_type_hint),
])
.send()
.await?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
let payload = resp.text().await?;
Err(AppResponseError::new(
ErrorCode::AppleRevokeTokenError,
format!(
"calling apple revoke, code: {}, message: {}",
status, payload
),
))
}

View file

@ -39,9 +39,9 @@ use crate::state::GoTrueAdmin;
const MAX_COMMENT_LENGTH: usize = 5000;
pub async fn delete_workspace_for_user(
pg_pool: &PgPool,
workspace_id: &Uuid,
bucket_storage: &Arc<S3BucketStorage>,
pg_pool: PgPool,
workspace_id: Uuid,
bucket_storage: Arc<S3BucketStorage>,
) -> Result<(), AppResponseError> {
// remove files from s3
bucket_storage
@ -49,7 +49,7 @@ pub async fn delete_workspace_for_user(
.await?;
// remove from postgres
delete_from_workspace(pg_pool, workspace_id).await?;
delete_from_workspace(&pg_pool, &workspace_id).await?;
// TODO: There can be a rare case where user uploads while workspace is being deleted.
// We need some routine job to clean up these orphaned files.
@ -347,7 +347,7 @@ pub async fn invite_workspace_members(
.begin()
.await
.context("Begin transaction to invite workspace members")?;
let admin_token = gotrue_admin.token(gotrue_client).await?;
let admin_token = gotrue_admin.token().await?;
let inviter_name = database::user::select_name_from_uuid(pg_pool, inviter).await?;
let workspace_name =

View file

@ -22,6 +22,7 @@ pub struct Config {
pub grpc_history: GrpcHistorySetting,
pub collab: CollabSetting,
pub mailer: MailerSetting,
pub apple_oauth: AppleOAuthSetting,
}
#[derive(serde::Deserialize, Clone, Debug)]
@ -32,6 +33,12 @@ pub struct MailerSetting {
pub smtp_password: Secret<String>,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct AppleOAuthSetting {
pub client_id: String,
pub client_secret: Secret<String>,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct CasbinSetting {
pub pool_size: u32,
@ -204,6 +211,10 @@ pub fn get_configuration() -> Result<Config, anyhow::Error> {
smtp_username: get_env_var("APPFLOWY_MAILER_SMTP_USERNAME", "sender@example.com"),
smtp_password: get_env_var("APPFLOWY_MAILER_SMTP_PASSWORD", "password").into(),
},
apple_oauth: AppleOAuthSetting {
client_id: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_ID", ""),
client_secret: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_SECRET", "").into(),
},
};
Ok(config)
}

View file

@ -150,20 +150,23 @@ impl AppMetrics {
#[derive(Debug, Clone)]
pub struct GoTrueAdmin {
pub gotrue_client: gotrue::api::Client,
pub admin_email: String,
pub password: Secret<String>,
}
impl GoTrueAdmin {
pub fn new(admin_email: String, password: String) -> Self {
pub fn new(admin_email: String, password: String, gotrue_client: gotrue::api::Client) -> Self {
Self {
admin_email,
password: password.into(),
gotrue_client,
}
}
pub async fn token(&self, client: &gotrue::api::Client) -> Result<String, AppError> {
let token = client
pub async fn token(&self) -> Result<String, AppError> {
let token = self
.gotrue_client
.token(&Grant::Password(PasswordGrant {
email: self.admin_email.clone(),
password: self.password.expose_secret().clone(),

View file

@ -1,6 +1,35 @@
use client_api_test::*;
use gotrue::params::{AdminDeleteUserParams, AdminUserParams};
#[tokio::test]
async fn user_delete_self() {
let (client, user) = generate_unique_registered_user_client().await;
let admin_client = admin_user_client().await;
{
// user found before deletion
let search_result = admin_client
.admin_list_users(Some(&user.email))
.await
.unwrap();
let _target_user = search_result
.into_iter()
.find(|u| u.email == user.email)
.unwrap();
}
client.delete_user().await.unwrap();
{
// user cannot be found after deletion
let search_result = admin_client
.admin_list_users(Some(&user.email))
.await
.unwrap();
let target_user = search_result.into_iter().find(|u| u.email == user.email);
assert!(target_user.is_none(), "User should be deleted: {:?}", user);
}
}
#[tokio::test]
async fn admin_delete_create_same_user_hard() {
let (client, user) = generate_unique_registered_user_client().await;