Skip to content

Commit

Permalink
feat: Gotrue admin api (AppFlowy-IO#73)
Browse files Browse the repository at this point in the history
* feat: set up admin account and auto confirm during appflowy start

* feat: client auth against gotrue whenever possible

* feat: admin add user

* feat: implement admin add user

* feat: generate registered user

* fix: enable cloud feature for client_api

* fix: test same user fix
  • Loading branch information
speed2exe authored Sep 23, 2023
1 parent cbae949 commit f1a1605
Show file tree
Hide file tree
Showing 37 changed files with 476 additions and 538 deletions.
17 changes: 0 additions & 17 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,11 @@ jobs:
sed -i 's/GOTRUE_SMTP_USER=.*/GOTRUE_SMTP_USER=${{ secrets.GOTRUE_SMTP_USER }}/' .env
sed -i 's/GOTRUE_SMTP_PASS=.*/GOTRUE_SMTP_PASS=${{ secrets.GOTRUE_SMTP_PASS }}/' .env
sed -i 's/GOTRUE_SMTP_ADMIN_EMAIL=.*/GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.GOTRUE_SMTP_ADMIN_EMAIL }}/' .env
sed -i 's/GOTRUE_REGISTERED_EMAIL=.*/GOTRUE_REGISTERED_EMAIL=$GOTRUE_REGISTERED_EMAIL/' .env
sed -i 's/GOTRUE_REGISTERED_PASSWORD=.*/GOTRUE_REGISTERED_PASSWORD=$GOTRUE_REGISTERED_PASSWORD/' .env
sed -i 's/GOTRUE_MAILER_AUTOCONFIRM=.*/GOTRUE_MAILER_AUTOCONFIRM=true/' .env
- name: Run Docker-Compose
run: |
docker-compose up -d
- name: Add registered user
run: |
# temporary allow signup without email verification
export GOTRUE_MAILER_AUTOCONFIRM=true
docker-compose up -d
sleep 5 # sometimes the gotrue server may not be ready yet
./build/init_registered_user.sh
# revert to require signup email verification
export GOTRUE_MAILER_AUTOCONFIRM=false
docker-compose up -d
- name: Run tests
run: |
cargo install sqlx-cli --version=${{ env.SQLX_VERSION }} --features ${{ env.SQLX_FEATURES }} --no-default-features --locked
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 0 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,23 +83,6 @@ cargo run

### Run the tests

#### Verified user
- Make sure you have registered a user and put into your `.env` file, else some test may fail
- You may register the email defined in `.env` by running the following command, you may then click on the link sent to that email to complete registration
```bash
source .env
curl localhost:9998/signup \
--data-raw '{"email":"'"$GOTRUE_REGISTERED_EMAIL"'","password":"'"$GOTRUE_REGISTERED_PASSWORD"'"}' \
--header 'Content-Type: application/json'
```
- Verify registration, you should get a token after running the command below:
```bash
source .env
curl localhost:9998/token?grant_type=password \
--data-raw '{"email":"'"$GOTRUE_REGISTERED_EMAIL"'","password":"'"$GOTRUE_REGISTERED_PASSWORD"'"}' \
--header 'Content-Type: application/json'
```

#### Test
```bash
cargo test
Expand Down
20 changes: 0 additions & 20 deletions build/init_registered_user.sh

This file was deleted.

2 changes: 2 additions & 0 deletions configuration/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ redis_uri: "redis://127.0.0.1:6379"
gotrue:
base_url: "http://127.0.0.1:9999"
ext_url: "http://127.0.0.1:9999"
admin_email: admin@example.com
admin_password: password
s3:
use_minio: true
minio_url: http://localhost:9000
Expand Down
12 changes: 4 additions & 8 deletions dev.env
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,14 @@ GOTRUE_SMTP_USER=email_sender@some_company.com
GOTRUE_SMTP_PASS=email_sender_password
GOTRUE_SMTP_ADMIN_EMAIL=comp_admin@@some_company.com

# gotrue admin
GOTRUE_ADMIN_EMAIL=admin@example.com
GOTRUE_ADMIN_PASSWORD=password

# clicking on email verification link will redirect to this host
# change this to your own domain where you host the docker-compose or gotrue
API_EXTERNAL_URL=http://localhost:9998

# accounts that will be picked up by the tests
GOTRUE_REGISTERED_EMAIL_1=user1@example.com
GOTRUE_REGISTERED_PASSWORD_1=Password123!
GOTRUE_REGISTERED_EMAIL_2=user2@example.com
GOTRUE_REGISTERED_PASSWORD_2=Password456!
GOTRUE_REGISTERED_EMAIL_3=user3@example.com
GOTRUE_REGISTERED_PASSWORD_3=Password789!

# url to the postgres database
DATABASE_URL=postgres://postgres:password@localhost:5433/postgres
# uncomment this to enable build without database
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ services:
- APP_ENVIRONMENT=production
- APP__GOTRUE__JWT_SECRET=${GOTRUE_JWT_SECRET}
- APP__GOTRUE__EXT_URL=${API_EXTERNAL_URL}
- APP__GOTRUE__ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL}
- APP__GOTRUE__ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD}
- APP__S3__USE_MINIO=${USE_MINIO}
- APP__S3__MINIO_URL=${MINIO_URL:-http://minio:9000}
- APP__S3__AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
Expand Down
1 change: 1 addition & 0 deletions libs/client-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ reqwest = { version = "0.11.20", default-features = false, features = ["json","m
anyhow = "1.0.75"
serde_json = "1.0.105"
serde_repr = "0.1.16"
gotrue = { path = "../gotrue" }
gotrue-entity = { path = "../gotrue-entity" }
shared_entity = { path = "../shared-entity" }
storage-entity = { path = "../storage-entity" }
Expand Down
175 changes: 109 additions & 66 deletions libs/client-api/src/http.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use gotrue::grant::Grant;
use gotrue::grant::PasswordGrant;
use gotrue::grant::RefreshTokenGrant;
use gotrue::params::AdminUserParams;
use gotrue_entity::OAuthProvider;
use gotrue_entity::OAuthURL;
use gotrue_entity::SignUpResponse::{Authenticated, NotAuthenticated};
use parking_lot::RwLock;
use reqwest::Method;
use reqwest::RequestBuilder;
use shared_entity::data::AppResponse;
use shared_entity::dto::SignInParams;
use shared_entity::dto::SignInPasswordResponse;
use shared_entity::dto::SignInTokenResponse;
use shared_entity::dto::UpdateUsernameParams;
use shared_entity::dto::UserUpdateParams;
use shared_entity::dto::WorkspaceMembersParams;
use std::sync::Arc;
Expand All @@ -24,18 +27,20 @@ use storage_entity::{AFWorkspaces, QueryCollabParams};
use storage_entity::{DeleteCollabParams, RawData};

pub struct Client {
http_client: reqwest::Client,
cloud_client: reqwest::Client,
gotrue_client: gotrue::api::Client,
base_url: String,
ws_addr: String,
token: Arc<RwLock<ClientToken>>,
}

impl Client {
pub fn from(c: reqwest::Client, base_url: &str, ws_addr: &str) -> Self {
pub fn from(c: reqwest::Client, base_url: &str, ws_addr: &str, gotrue_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
ws_addr: ws_addr.to_string(),
http_client: c,
cloud_client: c.clone(),
gotrue_client: gotrue::api::Client::new(c, gotrue_url),
token: Arc::new(RwLock::new(ClientToken::new())),
}
}
Expand Down Expand Up @@ -88,7 +93,7 @@ impl Client {
})?;

let access_token = access_token.ok_or(url_missing_param("access_token"))?;
let (user, new) = self.sign_in_token(&access_token).await?;
let (user, new) = self.verify_token(&access_token).await?;

self.token.write().set(AccessTokenResponse {
access_token,
Expand All @@ -104,11 +109,37 @@ impl Client {
Ok(new)
}

pub async fn sign_in_token(&self, access_token: &str) -> Result<(User, bool), AppError> {
let url = format!("{}/api/user/sign_in/token/{}", self.base_url, access_token);
let resp = self.http_client.get(&url).send().await?;
async fn verify_token(&self, access_token: &str) -> Result<(User, bool), AppError> {
let user = self.gotrue_client.user_info(access_token).await?;
let is_new = self.verify_token_cloud(access_token).await?;
Ok((user, is_new))
}

async fn verify_token_cloud(&self, access_token: &str) -> Result<bool, AppError> {
let url = format!("{}/api/user/verify/{}", self.base_url, access_token);
let resp = self.cloud_client.get(&url).send().await?;
let sign_in_resp: SignInTokenResponse = AppResponse::from_response(resp).await?.into_data()?;
Ok((sign_in_resp.user, sign_in_resp.is_new))
Ok(sign_in_resp.is_new)
}

pub async fn create_email_verified_user(
&self,
email: &str,
password: &str,
) -> Result<User, AppError> {
let user = self
.gotrue_client
.admin_add_user(
&self.access_token()?,
&AdminUserParams {
email: email.to_owned(),
password: Some(password.to_owned()),
email_confirm: true,
..Default::default()
},
)
.await?;
Ok(user)
}

/// Only expose this method for testing
Expand Down Expand Up @@ -137,13 +168,18 @@ impl Client {
}
}

pub async fn oauth_login(&self, provider: OAuthProvider) -> Result<(), AppError> {
let url = format!("{}/api/user/oauth/{}", self.base_url, provider.as_str());
let resp = self.http_client.get(&url).send().await?;
let oauth_url = AppResponse::<OAuthURL>::from_response(resp)
.await?
.into_data()?;
opener::open(oauth_url.url.as_str())?;
pub async fn oauth_login(&self, provider: &OAuthProvider) -> Result<(), AppError> {
let settings = self.gotrue_client.settings().await?;
if !settings.external.has_provider(provider) {
return Err(ErrorCode::InvalidOAuthProvider.into());
}

let oauth_url = format!(
"{}/authorize?provider={}",
self.gotrue_client.base_url,
provider.as_str(),
);
opener::open(oauth_url)?;
Ok(())
}

Expand Down Expand Up @@ -230,74 +266,81 @@ impl Client {
}

pub async fn sign_in_password(&self, email: &str, password: &str) -> Result<bool, AppError> {
let url = format!("{}/api/user/sign_in/password", self.base_url);
let params = SignInParams {
email: email.to_owned(),
password: password.to_owned(),
};
let resp = self.http_client.post(&url).json(&params).send().await?;
let sign_in_resp: SignInPasswordResponse =
AppResponse::from_response(resp).await?.into_data()?;
self.token.write().set(sign_in_resp.access_token_resp);
Ok(sign_in_resp.is_new)
let access_token_resp = self
.gotrue_client
.token(&Grant::Password(PasswordGrant {
email: email.to_owned(),
password: password.to_owned(),
}))
.await?;
let is_new = self
.verify_token_cloud(&access_token_resp.access_token)
.await?;
self.token.write().set(access_token_resp);
Ok(is_new)
}

pub async fn refresh(&self) -> Result<(), AppError> {
let url = format!(
"{}/api/user/refresh/{}",
self.base_url,
self
.token
.read()
.as_ref()
.ok_or::<AppError>(ErrorCode::NotLoggedIn.into())?
.refresh_token
.as_str()
);
let resp = self.http_client.get(&url).send().await?;
let token = AppResponse::from_response(resp).await?.into_data()?;
self.token.write().set(token);
let refresh_token = self
.token
.read()
.as_ref()
.ok_or::<AppError>(ErrorCode::NotLoggedIn.into())?
.refresh_token
.as_str()
.to_owned();
let access_token_resp = self
.gotrue_client
.token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token }))
.await?;
self.token.write().set(access_token_resp);
Ok(())
}

pub async fn sign_up(&self, email: &str, password: &str) -> Result<(), AppError> {
let url = format!("{}/api/user/sign_up", self.base_url);
let params = SignInParams {
email: email.to_owned(),
password: password.to_owned(),
};
let resp = self.http_client.post(&url).json(&params).send().await?;
AppResponse::<()>::from_response(resp).await?.into_error()?;
Ok(())
match self.gotrue_client.sign_up(email, password).await? {
Authenticated(access_token_resp) => {
self.token.write().set(access_token_resp);
Ok(())
},
NotAuthenticated(user) => {
tracing::info!("sign_up but not authenticated: {}", user.email);
Ok(())
},
}
}

pub async fn sign_out(&self) -> Result<(), AppError> {
let url = format!("{}/api/user/sign_out", self.base_url);
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.send()
.await?;
AppResponse::<()>::from_response(resp).await?.into_error()?;
self.token.write().unset();
self.gotrue_client.logout(&self.access_token()?).await?;
Ok(())
}

pub async fn update(&self, params: UserUpdateParams) -> Result<(), AppError> {
let updated_user = self
.gotrue_client
.update_user(&self.access_token()?, params.email, params.password)
.await?;
if let Some(t) = self.token.write().as_mut() {
t.user = updated_user;
}
if let Some(name) = params.name {
self.update_user_name(&name).await?;
}
Ok(())
}

pub async fn update_user_name(&self, new_name: &str) -> Result<(), AppError> {
let url = format!("{}/api/user/update", self.base_url);
let params = UpdateUsernameParams {
new_name: new_name.to_string(),
};
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.json(&params)
.send()
.await?;
let new_user = AppResponse::<User>::from_response(resp)
.await?
.into_data()?;
if let Some(t) = self.token.write().as_mut() {
t.user = new_user;
}
Ok(())
AppResponse::<()>::from_response(resp).await?.into_error()
}

pub async fn create_collab(&self, params: InsertCollabParams) -> Result<(), AppError> {
Expand Down Expand Up @@ -370,7 +413,7 @@ impl Client {

let access_token = self.access_token()?;
let request_builder = self
.http_client
.cloud_client
.request(method, url)
.bearer_auth(access_token);
Ok(request_builder)
Expand Down
Loading

0 comments on commit f1a1605

Please sign in to comment.