mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
961 lines
27 KiB
Rust
961 lines
27 KiB
Rust
use std::{collections::HashMap, fmt::Debug};
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
async_trait,
|
|
body::Bytes,
|
|
extract::{Extension, FromRequest, Path, Query, RequestParts},
|
|
response::Redirect,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use hyper::StatusCode;
|
|
use itertools::Itertools;
|
|
|
|
use oauth2::{Client as OClient, *};
|
|
use reqwest::Client;
|
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
|
use slack_http_verifier::SlackVerifier;
|
|
use sqlx::{Postgres, Transaction};
|
|
use tokio::{fs::File, io::AsyncReadExt};
|
|
use tower_cookies::{Cookie, Cookies};
|
|
|
|
use crate::utils::now_from_db;
|
|
use crate::IsSecure;
|
|
use crate::{
|
|
audit::{audit_log, ActionKind},
|
|
db::{UserDB, DB},
|
|
error::{self, to_anyhow, Error, Result},
|
|
jobs,
|
|
jobs::{get_latest_hash_for_path, JobPayload},
|
|
users::Authed,
|
|
utils::not_found_if_none,
|
|
variables::{build_crypt, encrypt},
|
|
workspaces::WorkspaceSettings,
|
|
BaseUrl,
|
|
};
|
|
|
|
pub fn global_service() -> Router {
|
|
Router::new()
|
|
.route("/login/:client", get(login))
|
|
.route("/login_callback/:client", post(login_callback))
|
|
.route("/connect/:client", get(connect))
|
|
.route("/connect_callback/:client", post(connect_callback))
|
|
.route("/connect_slack", get(connect_slack))
|
|
.route("/connect_slack_callback", post(connect_slack_callback))
|
|
.route(
|
|
"/slack_command",
|
|
post(slack_command).route_layer(axum::middleware::from_extractor::<SlackSig>()),
|
|
)
|
|
.route("/list_logins", get(list_logins))
|
|
.route("/list_connects", get(list_connects))
|
|
}
|
|
|
|
pub fn workspaced_service() -> Router {
|
|
Router::new()
|
|
.route("/disconnect/:id", post(disconnect))
|
|
.route("/disconnect_slack", post(disconnect_slack))
|
|
.route("/set_workspace_slack", post(set_workspace_slack))
|
|
.route("/create_account", post(create_account))
|
|
.route("/delete_account/:id", post(delete_account))
|
|
.route("/refresh_token/:id", post(refresh_token))
|
|
}
|
|
|
|
pub struct ClientWithScopes {
|
|
client: OClient,
|
|
scopes: Vec<String>,
|
|
extra_params: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
pub type BasicClientsMap = HashMap<String, ClientWithScopes>;
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct OAuthConfig {
|
|
auth_url: String,
|
|
token_url: String,
|
|
scopes: Option<Vec<String>>,
|
|
extra_params: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct OAuthClient {
|
|
id: String,
|
|
secret: String,
|
|
}
|
|
pub struct AllClients {
|
|
pub logins: BasicClientsMap,
|
|
pub connects: BasicClientsMap,
|
|
pub slack: Option<OClient>,
|
|
}
|
|
|
|
pub async fn build_oauth_clients(base_url: &str) -> anyhow::Result<AllClients> {
|
|
let connect_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(include_str!(
|
|
"../oauth_connect.json"
|
|
))?;
|
|
let login_configs =
|
|
serde_json::from_str::<HashMap<String, OAuthConfig>>(include_str!("../oauth_login.json"))?;
|
|
|
|
let mut content = String::new();
|
|
let path = "./oauth.json";
|
|
if std::path::Path::new(path).exists() {
|
|
let mut file = File::open(path).await?;
|
|
file.read_to_string(&mut content).await?;
|
|
} else {
|
|
content.push_str("{}");
|
|
}
|
|
|
|
let oauths: HashMap<String, OAuthClient> =
|
|
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
|
|
Ok(clients) => clients,
|
|
Err(e) => {
|
|
tracing::error!("deserializing oauth.json: {e}");
|
|
HashMap::new()
|
|
}
|
|
}
|
|
.into_iter()
|
|
.collect();
|
|
|
|
tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", "));
|
|
|
|
let logins = login_configs
|
|
.into_iter()
|
|
.filter(|x| oauths.contains_key(&x.0))
|
|
.map(|(k, v)| {
|
|
let scopes = v.scopes.clone();
|
|
let extra_params = v.extra_params.clone();
|
|
let named_client = build_basic_client(
|
|
k.clone(),
|
|
v,
|
|
oauths.get(&k).unwrap().clone(),
|
|
true,
|
|
base_url,
|
|
);
|
|
(
|
|
named_client.0,
|
|
ClientWithScopes {
|
|
client: named_client.1,
|
|
scopes: scopes.unwrap_or(vec![]),
|
|
extra_params,
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let connects = connect_configs
|
|
.into_iter()
|
|
.filter(|x| oauths.contains_key(&x.0))
|
|
.map(|(k, v)| {
|
|
let scopes = v.scopes.clone();
|
|
let extra_params = v.extra_params.clone();
|
|
|
|
let named_client = build_basic_client(
|
|
k.clone(),
|
|
v,
|
|
oauths.get(&k).unwrap().clone(),
|
|
false,
|
|
base_url,
|
|
);
|
|
(
|
|
named_client.0,
|
|
ClientWithScopes {
|
|
client: named_client.1,
|
|
scopes: scopes.unwrap_or(vec![]),
|
|
extra_params,
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let slack = oauths.get("slack").map(|v| {
|
|
build_basic_client(
|
|
"slack".to_string(),
|
|
OAuthConfig {
|
|
auth_url: "https://slack.com/oauth/authorize".to_string(),
|
|
token_url: "https://slack.com/api/oauth.access".to_string(),
|
|
scopes: None,
|
|
extra_params: None,
|
|
},
|
|
v.clone(),
|
|
false,
|
|
base_url,
|
|
)
|
|
.1
|
|
});
|
|
|
|
Ok(AllClients { logins, connects, slack })
|
|
}
|
|
|
|
pub fn build_basic_client(
|
|
name: String,
|
|
config: OAuthConfig,
|
|
client_params: OAuthClient,
|
|
login: bool,
|
|
base_url: &str,
|
|
) -> (String, OClient) {
|
|
let auth_url = Url::parse(&config.auth_url).expect("Invalid authorization endpoint URL");
|
|
let token_url = Url::parse(&config.token_url).expect("Invalid token endpoint URL");
|
|
|
|
let redirect_url = if login {
|
|
format!("{base_url}/user/login_callback/{name}")
|
|
} else {
|
|
format!("{base_url}/oauth/callback/{name}")
|
|
};
|
|
|
|
let mut client = OClient::new(client_params.id, auth_url, token_url);
|
|
client.set_client_secret(client_params.secret.clone());
|
|
client.set_redirect_url(Url::parse(&redirect_url).expect("Invalid redirect URL"));
|
|
// Set up the config for the Github OAuth2 process.
|
|
(name.to_string(), client)
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct SlackTokenResponse {
|
|
access_token: AccessToken,
|
|
team_id: String,
|
|
team_name: String,
|
|
#[serde(rename = "scope")]
|
|
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
|
|
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[serde(default)]
|
|
scopes: Option<Vec<Scope>>,
|
|
bot: SlackBotToken,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct TokenResponse {
|
|
access_token: AccessToken,
|
|
expires_in: Option<u64>,
|
|
refresh_token: Option<RefreshToken>,
|
|
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
|
|
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
|
|
#[serde(default)]
|
|
scope: Option<Vec<Scope>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct SlackBotToken {
|
|
bot_access_token: String,
|
|
}
|
|
|
|
async fn connect(
|
|
Path(client_name): Path<String>,
|
|
Query(query): Query<HashMap<String, String>>,
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
cookies: Cookies,
|
|
) -> error::Result<Redirect> {
|
|
let mut query = query.clone();
|
|
let connects = &clients.connects;
|
|
let scopes = query
|
|
.get("scopes")
|
|
.map(|x| x.split('+').map(|x| x.to_owned()).collect());
|
|
query.remove("scopes");
|
|
let extra_params = if query.is_empty() {
|
|
None
|
|
} else {
|
|
Some(query.clone())
|
|
};
|
|
oauth_redirect(connects, client_name, cookies, scopes, extra_params)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CreateAccount {
|
|
client: String,
|
|
owner: String,
|
|
refresh_token: String,
|
|
expires_in: i64,
|
|
}
|
|
async fn create_account(
|
|
authed: Authed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(w_id): Path<String>,
|
|
Json(payload): Json<CreateAccount>,
|
|
) -> error::Result<String> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let id = sqlx::query_scalar!(
|
|
"INSERT INTO account (workspace_id, client, owner, expires_at, refresh_token) VALUES ($1, \
|
|
$2, $3, now() + ($4 || ' seconds')::interval, $5) RETURNING id",
|
|
w_id,
|
|
payload.client,
|
|
payload.owner,
|
|
payload.expires_in.to_string(),
|
|
payload.refresh_token
|
|
)
|
|
.fetch_one(&mut tx)
|
|
.await
|
|
.map_err(|e| Error::InternalErr(format!("creating account in {w_id}: {e}")))?;
|
|
tx.commit().await?;
|
|
Ok(id.to_string())
|
|
}
|
|
|
|
async fn delete_account(
|
|
authed: Authed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Query((w_id, id)): Query<(String, i32)>,
|
|
) -> error::Result<String> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let exists = sqlx::query!(
|
|
"DELETE FROM account WHERE workspace_id = $1 AND id = $2 RETURNING id",
|
|
w_id,
|
|
id,
|
|
)
|
|
.fetch_optional(&mut tx)
|
|
.await?;
|
|
|
|
let id_str = id.to_string();
|
|
not_found_if_none(exists, "Account", &id_str)?;
|
|
|
|
audit_log(
|
|
&mut tx,
|
|
&authed.username,
|
|
"account.delete",
|
|
ActionKind::Delete,
|
|
&w_id,
|
|
Some(&id_str),
|
|
None,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(format!("Deleted account id {id}"))
|
|
}
|
|
|
|
async fn list_logins(
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
) -> error::JsonResult<Vec<String>> {
|
|
Ok(Json(
|
|
clients
|
|
.logins
|
|
.keys()
|
|
.map(|x| x.to_owned())
|
|
.collect::<Vec<String>>(),
|
|
))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ScopesAndParams {
|
|
scopes: Vec<String>,
|
|
extra_params: Option<HashMap<String, String>>,
|
|
}
|
|
async fn list_connects(
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
) -> error::JsonResult<HashMap<String, ScopesAndParams>> {
|
|
Ok(Json(
|
|
(&clients.connects)
|
|
.into_iter()
|
|
.map(|(k, v)| {
|
|
(
|
|
k.to_owned(),
|
|
ScopesAndParams {
|
|
scopes: v.scopes.clone(),
|
|
extra_params: v.extra_params.clone(),
|
|
},
|
|
)
|
|
})
|
|
.collect::<HashMap<String, ScopesAndParams>>(),
|
|
))
|
|
}
|
|
|
|
async fn connect_slack(
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
cookies: Cookies,
|
|
) -> error::Result<Redirect> {
|
|
let mut client = clients
|
|
.slack
|
|
.as_ref()
|
|
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
|
|
.to_owned();
|
|
let state = State::new_random();
|
|
|
|
client.add_scope("bot");
|
|
client.add_scope("commands");
|
|
let url = client.authorize_url(&state);
|
|
|
|
set_cookie(&state, cookies);
|
|
Ok(Redirect::to(url.as_str()))
|
|
}
|
|
|
|
async fn disconnect(
|
|
authed: Authed,
|
|
Path((w_id, id)): Path<(String, i32)>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
) -> error::Result<String> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM account WHERE id = $1 AND workspace_id = $2",
|
|
id,
|
|
w_id
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
Ok(format!("account {id} disconnected"))
|
|
}
|
|
|
|
async fn disconnect_slack(
|
|
authed: Authed,
|
|
Path(w_id): Path<String>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
) -> error::Result<String> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings
|
|
SET slack_team_id = null, slack_name = null WHERE workspace_id = $1",
|
|
&w_id
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
Ok(format!("slack disconnected"))
|
|
}
|
|
|
|
async fn login(
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
Path(client_name): Path<String>,
|
|
cookies: Cookies,
|
|
) -> error::Result<Redirect> {
|
|
let clients = &clients.logins;
|
|
oauth_redirect(clients, client_name, cookies, None, None)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct VariablePath {
|
|
path: String,
|
|
}
|
|
async fn refresh_token(
|
|
authed: Authed,
|
|
Path((w_id, id)): Path<(String, i32)>,
|
|
Json(VariablePath { path }): Json<VariablePath>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
Extension(http_client): Extension<Client>,
|
|
) -> error::Result<String> {
|
|
let tx = user_db.begin(&authed).await?;
|
|
|
|
_refresh_token(tx, &path, w_id, id, clients, http_client).await?;
|
|
|
|
Ok(format!("Token at path {path} refreshed"))
|
|
}
|
|
|
|
pub async fn _refresh_token<'c>(
|
|
mut tx: Transaction<'c, Postgres>,
|
|
path: &str,
|
|
w_id: String,
|
|
id: i32,
|
|
clients: Arc<AllClients>,
|
|
http_client: Client,
|
|
) -> error::Result<String> {
|
|
let account = sqlx::query!(
|
|
"SELECT client, refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
|
w_id,
|
|
id,
|
|
)
|
|
.fetch_optional(&mut tx)
|
|
.await?;
|
|
let account = not_found_if_none(account, "Account", &id.to_string())?;
|
|
let client = (&clients
|
|
.connects
|
|
.get(&account.client)
|
|
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
|
.client)
|
|
.to_owned();
|
|
|
|
let token = client
|
|
.exchange_refresh_token(&RefreshToken::from(account.refresh_token.clone()))
|
|
.with_client(&http_client)
|
|
.execute::<TokenResponse>()
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
let expires_at = now_from_db(&mut tx).await?
|
|
+ chrono::Duration::seconds(
|
|
token
|
|
.expires_in
|
|
.ok_or_else(|| Error::InternalErr("expires_in exepcted and not found".to_string()))?
|
|
.try_into()
|
|
.unwrap(),
|
|
);
|
|
sqlx::query!(
|
|
"UPDATE account SET refresh_token = $1, expires_at = $2 WHERE workspace_id = $3 AND id = \
|
|
$4",
|
|
token
|
|
.refresh_token
|
|
.map(|x| x.to_string())
|
|
.unwrap_or(account.refresh_token),
|
|
expires_at,
|
|
w_id,
|
|
id
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
|
|
let token_str = token.access_token.to_string();
|
|
let mc = build_crypt(&mut tx, &w_id).await?;
|
|
let encrypted_token = encrypt(&mc, token_str.as_str());
|
|
|
|
sqlx::query!(
|
|
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
|
encrypted_token,
|
|
w_id,
|
|
path
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(token_str)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct OAuthCallback {
|
|
code: String,
|
|
state: String,
|
|
}
|
|
|
|
async fn connect_callback(
|
|
cookies: Cookies,
|
|
Path(client_name): Path<String>,
|
|
Json(callback): Json<OAuthCallback>,
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
Extension(http_client): Extension<Client>,
|
|
) -> error::JsonResult<TokenResponse> {
|
|
let client = (&clients
|
|
.connects
|
|
.get(&client_name)
|
|
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
|
.client)
|
|
.to_owned();
|
|
|
|
let token_response =
|
|
exchange_code::<TokenResponse>(callback, &cookies, client, &http_client).await?;
|
|
|
|
Ok(Json(token_response))
|
|
}
|
|
|
|
async fn connect_slack_callback(
|
|
cookies: Cookies,
|
|
Json(callback): Json<OAuthCallback>,
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
Extension(http_client): Extension<Client>,
|
|
) -> error::JsonResult<SlackTokenResponse> {
|
|
let client = clients
|
|
.slack
|
|
.as_ref()
|
|
.ok_or_else(|| error::Error::BadRequest("slack client not setup".to_string()))?
|
|
.to_owned();
|
|
let token =
|
|
exchange_code::<SlackTokenResponse>(callback, &cookies, client, &http_client).await?;
|
|
|
|
Ok(Json(token))
|
|
}
|
|
|
|
async fn set_workspace_slack(
|
|
Path(w_id): Path<String>,
|
|
Json(token): Json<SlackTokenResponse>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
authed: Authed,
|
|
) -> Result<String> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_settings
|
|
(workspace_id, slack_team_id, slack_name)
|
|
VALUES ($1, $2, $3) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, \
|
|
slack_name = $3",
|
|
&w_id,
|
|
token.team_id,
|
|
token.team_name
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO group_
|
|
(workspace_id, name, summary)
|
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
|
&w_id,
|
|
"slack",
|
|
"The group that runs the script triggered by the slack /windmill command.
|
|
Share scripts to this group to make them executable from slack and add
|
|
members to this group to let them manage the slack related owner space."
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok("slack workspace connected".to_string())
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
pub struct SlackCommand {
|
|
team_id: String,
|
|
user_name: String,
|
|
text: String,
|
|
response_url: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SlackSig {
|
|
sig: String,
|
|
ts: String,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<B> FromRequest<B> for SlackSig
|
|
where
|
|
B: Send,
|
|
{
|
|
type Rejection = (StatusCode, String);
|
|
|
|
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
|
|
let hm = req.headers();
|
|
Ok(Self {
|
|
sig: hm
|
|
.get("X-Slack-Signature")
|
|
.map(|x| x.to_str().unwrap_or(""))
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
ts: hm
|
|
.get("X-Slack-Request-Timestamp")
|
|
.map(|x| x.to_str().unwrap_or(""))
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn slack_command(
|
|
SlackSig { sig, ts }: SlackSig,
|
|
Extension(slack_verifier): Extension<Arc<Option<SlackVerifier>>>,
|
|
Extension(db): Extension<DB>,
|
|
Extension(base_url): Extension<Arc<BaseUrl>>,
|
|
body: Bytes,
|
|
) -> error::Result<String> {
|
|
let form: SlackCommand = serde_urlencoded::from_bytes(&body)
|
|
.map_err(|_| error::Error::BadRequest("invalid payload".to_string()))?;
|
|
|
|
let body = String::from_utf8_lossy(&body);
|
|
if slack_verifier
|
|
.as_ref()
|
|
.as_ref()
|
|
.map(|sv| sv.verify(&ts, &body, &sig).ok())
|
|
.flatten()
|
|
.is_none()
|
|
{
|
|
return Err(error::Error::BadRequest("verification failed".to_owned()));
|
|
}
|
|
|
|
let mut tx = db.begin().await?;
|
|
let settings = sqlx::query_as!(
|
|
WorkspaceSettings,
|
|
"SELECT * FROM workspace_settings WHERE slack_team_id = $1",
|
|
form.team_id,
|
|
)
|
|
.fetch_optional(&mut tx)
|
|
.await?;
|
|
|
|
if let Some(settings) = settings {
|
|
if let Some(script) = &settings.slack_command_script {
|
|
let script_hash =
|
|
get_latest_hash_for_path(&mut tx, &settings.workspace_id, script).await?;
|
|
let mut map = serde_json::Map::new();
|
|
map.insert("text".to_string(), serde_json::Value::String(form.text));
|
|
map.insert(
|
|
"response_url".to_string(),
|
|
serde_json::Value::String(form.response_url),
|
|
);
|
|
|
|
let (uuid, tx) = jobs::push(
|
|
tx,
|
|
&settings.workspace_id,
|
|
JobPayload::ScriptHash { hash: script_hash, path: script.to_owned() },
|
|
Some(map),
|
|
&form.user_name,
|
|
"g/slack".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
false,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
let url = base_url.0.to_owned();
|
|
return Ok(format!("Job launched. See details at {url}/run/{uuid}"));
|
|
}
|
|
}
|
|
|
|
return Ok(format!(
|
|
"workspace not properly configured (did you set the script to trigger in the settings?)"
|
|
));
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
#[derive(Deserialize)]
|
|
pub struct UserInfo {
|
|
name: Option<String>,
|
|
company: Option<String>,
|
|
displayName: Option<String>,
|
|
}
|
|
|
|
async fn login_callback(
|
|
Path(client_name): Path<String>,
|
|
Json(callback): Json<OAuthCallback>,
|
|
cookies: Cookies,
|
|
Extension(clients): Extension<Arc<AllClients>>,
|
|
Extension(db): Extension<DB>,
|
|
Extension(http_client): Extension<Client>,
|
|
Extension(is_secure): Extension<Arc<IsSecure>>,
|
|
) -> error::Result<String> {
|
|
let client = (&clients
|
|
.logins
|
|
.get(&client_name)
|
|
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
|
.client)
|
|
.to_owned();
|
|
let token_res = exchange_code::<TokenResponse>(callback, &cookies, client, &http_client).await;
|
|
|
|
if let Ok(token) = token_res {
|
|
let token = &token.access_token.to_string();
|
|
|
|
let email = get_email(&http_client, &client_name, token).await?;
|
|
|
|
let mut tx = db.begin().await?;
|
|
|
|
let login: Option<(String, String, bool)> =
|
|
sqlx::query_as("SELECT email, login_type, super_admin FROM password WHERE email = $1")
|
|
.bind(&email)
|
|
.fetch_optional(&mut tx)
|
|
.await?;
|
|
|
|
if let Some((email, login_type, super_admin)) = login {
|
|
let login_type = serde_json::json!(login_type);
|
|
if login_type == client_name {
|
|
crate::users::create_session_token(
|
|
&email,
|
|
super_admin,
|
|
&mut tx,
|
|
cookies,
|
|
is_secure.0,
|
|
)
|
|
.await?;
|
|
} else {
|
|
return Err(error::Error::BadRequest(format!(
|
|
"an user with the email associated to this login exists but with a different \
|
|
login type {login_type}"
|
|
)));
|
|
}
|
|
} else {
|
|
let user = get_user_info(&http_client, &client_name, &token).await?;
|
|
|
|
sqlx::query(&format!(
|
|
"INSERT INTO password (email, name, company, login_type, verified) VALUES ($1, \
|
|
$2, $3, '{}', true)",
|
|
&client_name
|
|
))
|
|
.bind(&email)
|
|
.bind(&user.name)
|
|
.bind(user.company)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
crate::users::create_session_token(&email, false, &mut tx, cookies, is_secure.0)
|
|
.await?;
|
|
audit_log(
|
|
&mut tx,
|
|
&email,
|
|
"oauth.signup",
|
|
ActionKind::Create,
|
|
"global",
|
|
Some("github"),
|
|
None,
|
|
)
|
|
.await?;
|
|
let demo_exists =
|
|
sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'demo')")
|
|
.fetch_one(&mut tx)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if demo_exists {
|
|
sqlx::query!(
|
|
"INSERT INTO workspace_invite
|
|
(workspace_id, email, is_admin)
|
|
VALUES ('demo', $1, false)",
|
|
&email
|
|
)
|
|
.execute(&mut tx)
|
|
.await?;
|
|
}
|
|
}
|
|
tx.commit().await?;
|
|
Ok("Successfully logged in".to_string())
|
|
} else {
|
|
Err(error::Error::BadRequest(format!(
|
|
"failed to exchange code: {:?}",
|
|
token_res.err().unwrap()
|
|
)))
|
|
}
|
|
}
|
|
|
|
async fn exchange_code<T: DeserializeOwned>(
|
|
callback: OAuthCallback,
|
|
cookies: &Cookies,
|
|
client: OClient,
|
|
http_client: &Client,
|
|
) -> error::Result<T> {
|
|
let csrf_state = cookies
|
|
.get("csrf")
|
|
.map(|x| x.value().to_string())
|
|
.unwrap_or("".to_string());
|
|
if callback.state != csrf_state {
|
|
return Err(error::Error::BadRequest("csrf did not match".to_string()));
|
|
}
|
|
|
|
client
|
|
.exchange_code(callback.code)
|
|
.with_client(http_client)
|
|
.execute::<T>()
|
|
.await
|
|
.map_err(|e| error::Error::InternalErr(format!("{:?}", e)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct GHEmailInfo {
|
|
email: String,
|
|
verified: bool,
|
|
primary: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct EmailInfo {
|
|
email: String,
|
|
}
|
|
|
|
async fn get_email(http_client: &Client, client_name: &str, token: &str) -> error::Result<String> {
|
|
let email = match client_name {
|
|
"github" => http_get_user_info::<Vec<GHEmailInfo>>(
|
|
http_client,
|
|
"https://api.github.com/user/emails",
|
|
token,
|
|
)
|
|
.await?
|
|
.iter()
|
|
.find(|x| x.primary && x.verified)
|
|
.ok_or(error::Error::BadRequest(format!(
|
|
"user does not have any primary and verified address"
|
|
)))?
|
|
.email
|
|
.to_string(),
|
|
"gitlab" => {
|
|
http_get_user_info::<EmailInfo>(http_client, "https://gitlab.com/api/v4/user", token)
|
|
.await?
|
|
.email
|
|
.to_string()
|
|
}
|
|
"google" => http_get_user_info::<EmailInfo>(
|
|
http_client,
|
|
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
token,
|
|
)
|
|
.await?
|
|
.email
|
|
.to_string(),
|
|
_ => {
|
|
return Err(error::Error::BadRequest(
|
|
"client name not recognized".to_string(),
|
|
))
|
|
}
|
|
};
|
|
Ok(email)
|
|
}
|
|
|
|
async fn get_user_info(
|
|
http_client: &Client,
|
|
client_name: &str,
|
|
token: &str,
|
|
) -> error::Result<UserInfo> {
|
|
let email = match client_name {
|
|
"github" => http_get_user_info(http_client, "https://api.github.com/user", token).await?,
|
|
"gitlab" => {
|
|
http_get_user_info(http_client, "https://gitlab.com/api/v4/user", token).await?
|
|
}
|
|
"google" => {
|
|
let google_user_info: UserInfo = http_get_user_info(
|
|
http_client,
|
|
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
token,
|
|
)
|
|
.await?;
|
|
UserInfo {
|
|
name: google_user_info.displayName.clone(),
|
|
company: None,
|
|
displayName: google_user_info.displayName,
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(error::Error::BadRequest(
|
|
"client name not recognized".to_string(),
|
|
))
|
|
}
|
|
};
|
|
Ok(email)
|
|
}
|
|
|
|
async fn http_get_user_info<T: DeserializeOwned>(
|
|
http_client: &Client,
|
|
url: &str,
|
|
token: &str,
|
|
) -> error::Result<T> {
|
|
Ok(http_client
|
|
.get(url)
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.map_err(to_anyhow)?
|
|
.json::<T>()
|
|
.await
|
|
.map_err(to_anyhow)?)
|
|
}
|
|
|
|
fn oauth_redirect(
|
|
clients: &HashMap<String, ClientWithScopes>,
|
|
client_name: String,
|
|
cookies: Cookies,
|
|
scopes: Option<Vec<String>>,
|
|
extra_params: Option<HashMap<String, String>>,
|
|
) -> error::Result<Redirect> {
|
|
let client_w_scopes = clients
|
|
.get(&client_name)
|
|
.ok_or_else(|| error::Error::BadRequest("client not found".to_string()))?;
|
|
let state = State::new_random();
|
|
let mut client = client_w_scopes.client.clone();
|
|
let scopes_iter = if let Some(scopes) = scopes {
|
|
scopes
|
|
} else {
|
|
client_w_scopes.scopes.clone()
|
|
};
|
|
|
|
for scope in scopes_iter.iter() {
|
|
client.add_scope(scope);
|
|
}
|
|
|
|
let mut auth_url = client.authorize_url(&state);
|
|
|
|
if let Some(extra_params) = extra_params {
|
|
let mut query_string = auth_url.query_pairs_mut();
|
|
for (key, value) in extra_params {
|
|
query_string.append_pair(&key, &value);
|
|
}
|
|
}
|
|
|
|
set_cookie(&state, cookies);
|
|
Ok(Redirect::to(auth_url.as_str()))
|
|
}
|
|
|
|
fn set_cookie(state: &State, cookies: Cookies) {
|
|
let csrf = state.to_base64();
|
|
let mut cookie = Cookie::new("csrf", csrf);
|
|
cookie.set_path("/");
|
|
cookies.add(cookie);
|
|
}
|