OAuth callbacks

This commit is contained in:
eraden 2024-01-25 17:15:02 +01:00
parent 56ef7c9318
commit 0923427551
14 changed files with 2072 additions and 819 deletions

65
Cargo.lock generated
View File

@ -86,9 +86,9 @@ dependencies = [
[[package]]
name = "actix-jwt-session"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "370a6266b24b472629abdbd222d9ea735c0ac86c81fa2a3851dee86c82d99521"
checksum = "6317d3303618eea36d68898bf720ce94d8b5bb2bf1a23c8ffdf714024f0a49a6"
dependencies = [
"actix-web",
"argon2",
@ -97,6 +97,7 @@ dependencies = [
"cookie 0.17.0",
"deadpool",
"deadpool-redis",
"derive_more",
"futures",
"futures-lite",
"futures-util",
@ -368,9 +369,9 @@ checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
[[package]]
name = "argon2"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ba4cac0a46bc1d2912652a751c47f2a9f3a7fe89bcae2275d418f5270402f9"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
@ -687,9 +688,9 @@ dependencies = [
[[package]]
name = "bytemuck"
version = "1.14.0"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6"
checksum = "ed2490600f404f2b94c167e31d3ed1d5f3c225a0f3b80230053b3e0b7b962bd9"
[[package]]
name = "byteorder"
@ -742,9 +743,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chrono"
version = "0.4.31"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb"
dependencies = [
"android-tzdata",
"iana-time-zone",
@ -753,7 +754,7 @@ dependencies = [
"pure-rust-locales",
"serde",
"wasm-bindgen",
"windows-targets 0.48.5",
"windows-targets 0.52.0",
]
[[package]]
@ -1075,6 +1076,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
[[package]]
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "dotenvy"
version = "0.15.7"
@ -1757,7 +1764,9 @@ dependencies = [
"actix-web",
"async-trait",
"bincode",
"chrono",
"derive_more",
"dotenv",
"entities",
"figment",
"futures",
@ -2320,9 +2329,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl"
version = "0.10.62"
version = "0.10.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671"
checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
@ -2352,9 +2361,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.98"
version = "0.9.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1665caf8ab2dc9aef43d1c0023bd904633a6a05cb30b0ad59bec2ae986e57a7"
checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae"
dependencies = [
"cc",
"libc",
@ -2626,9 +2635,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.76"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
@ -2808,13 +2817,13 @@ dependencies = [
[[package]]
name = "regex"
version = "1.10.2"
version = "1.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.3",
"regex-automata 0.4.5",
"regex-syntax 0.8.2",
]
@ -2829,9 +2838,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.3"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd"
dependencies = [
"aho-corasick",
"memchr",
@ -3224,9 +3233,9 @@ dependencies = [
[[package]]
name = "sea-orm"
version = "0.12.11"
version = "0.12.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59f66e2129991acd51fcad7b59da1edd862edca69773cc9a19cb17b967fae2fb"
checksum = "0cbf88748872fa54192476d6d49d0775e208566a72656e267e45f6980b926c8d"
dependencies = [
"async-stream",
"async-trait",
@ -3252,9 +3261,9 @@ dependencies = [
[[package]]
name = "sea-orm-macros"
version = "0.12.11"
version = "0.12.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b03da1864306242678ac3b6567e69f70dd1252a72742baa23a4d92d2d45da3fc"
checksum = "e0dbc880d47aa53c6a572e39c99402c7fad59b50766e51e0b0fc1306510b0555"
dependencies = [
"heck",
"proc-macro2",
@ -3553,9 +3562,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.13.0"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b187f0231d56fe41bfb12034819dd2bf336422a5866de41bc3fec4b2e3883e8"
checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
[[package]]
name = "smartstring"
@ -4280,9 +4289,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "uncased"
version = "0.9.9"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b9bc53168a4be7402ab86c3aad243a84dd7381d09be0eddc81280c1da95ca68"
checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
dependencies = [
"version_check",
]

View File

@ -1,6 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11
use super::sea_orm_active_enums::ProjectMemberInviteRoles;
use super::sea_orm_active_enums::ProjectMemberRoles;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@ -17,7 +17,7 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub message: Option<String>,
pub responded_at: Option<DateTimeWithTimeZone>,
pub role: ProjectMemberInviteRoles,
pub role: ProjectMemberRoles,
pub created_by_id: Option<Uuid>,
pub project_id: Uuid,
pub updated_by_id: Option<Uuid>,

View File

@ -1,6 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11
use super::sea_orm_active_enums::Roles;
use super::sea_orm_active_enums::ProjectMemberRoles;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@ -13,7 +13,7 @@ pub struct Model {
pub id: Uuid,
#[sea_orm(column_type = "Text", nullable)]
pub comment: Option<String>,
pub role: Roles,
pub role: ProjectMemberRoles,
pub created_by_id: Option<Uuid>,
pub member_id: Option<Uuid>,
pub project_id: Uuid,

View File

@ -7,9 +7,9 @@ use serde::{Deserialize, Serialize};
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "project_member_invite_roles"
enum_name = "project_member_roles"
)]
pub enum ProjectMemberInviteRoles {
pub enum ProjectMemberRoles {
#[sea_orm(string_value = "Admin")]
Admin,
#[sea_orm(string_value = "Guest")]

View File

@ -14,7 +14,7 @@ rumqttc = { version = "0.23.0", features = ["use-rustls"] }
rust-s3 = { version = "0.33.0", features = ["tokio-rustls-tls", "futures-util", "futures-io"] }
sea-orm = { version = "0.12.11", features = ["postgres-array", "sqlx-all"] }
serde = "1.0.195"
serde_json = "1.0.111"
serde_json = { version = "1.0.111", features = ["raw_value", "alloc"] }
tokio = { version = "1.35.1", features = ["full"] }
jet-contract = { workspace = true }
uuid = { version = "1.7.0", features = ["v4", "serde"] }
@ -39,3 +39,5 @@ oauth2-signin = "0.2.0"
oauth2-core = "0.2.0"
reqwest = { version = "0.11.23", default-features = false, features = ["rustls", "tokio-rustls", "tokio-socks", "multipart"] }
http-api-isahc-client = { version = "0.2.2", features = ["with-sleep-via-tokio"] }
dotenv = "0.15.0"
chrono = { version = "0.4.32", default-features = false, features = ["clock", "serde"] }

View File

@ -1,11 +1,14 @@
use actix_web::web::{Data, ServiceConfig};
use actix_web::{delete, get, HttpResponse};
use sea_orm::DatabaseConnection;
use uuid::Uuid;
mod email_check;
mod social_auth;
pub fn configure(config: &mut ServiceConfig) {
pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) {
config.service(email_check::email_check).service(oauth);
social_auth::configure(http_client, config);
}
#[derive(Debug, Clone, Copy, derive_more::Display)]
@ -18,7 +21,17 @@ pub enum PublishError {
MagicLinkEmail,
}
#[derive(Debug, Clone, Copy, derive_more::Display, derive_more::From)]
#[derive(Debug, Clone, derive_more::Display)]
pub enum OAuthError {
#[display(fmt = "Failed to load workspace invides for {user_id} on {provider} callback")]
FetchWorkspaceInvites { user_id: Uuid, provider: String },
#[display(fmt = "Failed to load project invides for {user_id} on {provider} callback")]
FetchProjectInvites { user_id: Uuid, provider: String },
#[display(fmt = "Failed to connect user {user_id} to {provider}")]
ConnectSocialMedia { user_id: Uuid, provider: String },
}
#[derive(Debug, Clone, derive_more::Display, derive_more::From)]
pub enum AuthError {
#[display(fmt = "New account creation is disabled. Please contact your site administrator")]
RegisterOff,
@ -37,6 +50,9 @@ pub enum AuthError {
#[display(fmt = "{}", _0)]
#[from]
Publish(PublishError),
#[display(fmt = "{}", _0)]
#[from]
Oauth(OAuthError),
}
#[get("/social-auth")]

View File

@ -0,0 +1,623 @@
use std::env::var as env_var;
use super::AuthError;
use actix_jwt_session::{JwtTtl, RefreshTtl, JWT_HEADER_NAME, REFRESH_HEADER_NAME};
use actix_web::{
get,
web::{self, Data, ServiceConfig},
HttpRequest, HttpResponse,
};
use chrono::Utc;
use entities::project_member_invites::{
Column as ProjectMemberInviteColumn, Model as ProjectMemberInvite,
};
use entities::users::{ActiveModel as UserModel, Column as UserColumn};
use entities::workspace_member_invites::{
Column as WorkspaceMemberInviteColumn, Model as WorkspaceMemberInvite,
};
use entities::workspace_members::ActiveModel as WorkspaceMemberModel;
use entities::{
prelude::{
ProjectMemberInvites, ProjectMembers, Users, WorkspaceMemberInvites, WorkspaceMembers,
},
sea_orm_active_enums::Roles,
};
use entities::{
project_members::ActiveModel as ProjectMemberModel, sea_orm_active_enums::ProjectMemberRoles,
};
use http_api_isahc_client::IsahcClient;
use jet_contract::{
event_bus::{Msg, SignInMedium, Topic, UserMsg},
UserId,
};
use oauth2_amazon::{
AmazonExtensionsBuilder, AmazonProviderWithWebServices, AmazonScope, AmazonTokenUrlRegion,
};
use oauth2_client::extensions::UserInfo;
use oauth2_github::{GithubExtensionsBuilder, GithubProviderWithWebApplication, GithubScope};
use oauth2_gitlab::{
GitlabExtensionsBuilder, GitlabProviderForEndUsers, GitlabScope, BASE_URL_GITLAB_COM,
};
use oauth2_google::{
GoogleExtensionsBuilder, GoogleProviderForWebServerApps,
GoogleProviderForWebServerAppsAccessType, GoogleScope,
};
use oauth2_signin::web_app::{SigninFlow, SigninFlowHandleCallbackByQueryConfiguration};
use reqwest::header::{LOCATION, USER_AGENT};
use sea_orm::{
sea_query::OnConflict, ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection,
DatabaseTransaction, EntityTrait, QueryFilter, Set, TransactionTrait,
};
use tracing::{debug, error, warn};
use crate::{
http::OAuthError,
models::{Error, JsonError},
session::AppClaims,
};
macro_rules! oauth_envs {
($env: expr, 2) => {{
use std::concat;
use std::env::var;
var(concat!($env, "_CLIENT_ID"))
.ok()
.zip(var(concat!($env, "_CLIENT_SECRET")).ok())
}};
($env: expr, 1) => {{
use std::concat;
use std::env::var;
var(concat!($env, "_CLIENT_ID")).ok()
}};
}
macro_rules! init_flow_service {
($provider: expr, $flow: expr) => {
web::resource(std::concat!("/auth/", $provider)).get({
let flow = $flow.clone();
move || {
let flow = flow.clone();
async move {
let flow = flow.clone();
let Ok(uri) = flow.build_authorization_url(None) else {
return HttpResponse::InternalServerError().finish();
};
HttpResponse::SeeOther()
.append_header((LOCATION, uri.as_str()))
.finish()
}
}
})
};
}
macro_rules! flow_callback {
($provider: expr, $flow: expr) => {{
web::resource(std::concat!("/auth/", $provider, "/callback")).get(
move |req: HttpRequest,
db: Data<DatabaseConnection>,
event_bus: Data<crate::EventBusClient>,
session: Data<actix_jwt_session::SessionStorage>| {
let flow = $flow.clone();
async move {
let res: Result<HttpResponse, Error> = handle_callback(
$provider,
req,
db,
flow.clone(),
event_bus.clone(),
session.clone(),
)
.await;
res
}
},
)
}};
}
pub fn configure(_http_client: reqwest::Client, config: &mut ServiceConfig) {
let schema = env_var("JET_API_SCHEMA").unwrap();
let host = env_var("JET_API_HOST").unwrap();
let uri = format!("{schema}://{host}");
let client = http_api_isahc_client::IsahcClient::new().unwrap();
if let Some((client_id, client_secret)) = oauth_envs!("GITHUB", 2) {
let flow = Data::new(github_flow(client.clone(), client_id, client_secret, &uri));
config
.service(init_flow_service!("github", flow))
.service(flow_callback!("github", flow));
}
if let Some((client_id, client_secret)) = oauth_envs!("GITLAB", 2) {
let flow = Data::new(gitlab_flow(client.clone(), client_id, client_secret, &uri));
config
.service(init_flow_service!("gitlab", flow))
.service(flow_callback!("gitlab", flow));
}
if let Some((client_id, client_secret)) = oauth_envs!("GOOGLE", 2) {
let flow = Data::new(google_flow(client.clone(), client_id, client_secret, &uri));
config
.service(init_flow_service!("google", flow))
.service(flow_callback!("google", flow));
}
if let Some((client_id, client_secret)) = oauth_envs!("AMAZON", 2) {
let flow = Data::new(amazon_flow(client.clone(), client_id, client_secret, &uri));
config
.service(init_flow_service!("amazon", flow))
.service(flow_callback!("amazon", flow));
}
config.service(social_auth);
}
#[get("/social-auth")]
async fn social_auth() -> HttpResponse {
HttpResponse::NotImplemented().finish()
}
async fn handle_callback(
provider: &str,
req: HttpRequest,
db: Data<DatabaseConnection>,
flow: Data<SigninFlow<IsahcClient>>,
event_bus: Data<crate::EventBusClient>,
session: Data<actix_jwt_session::SessionStorage>,
) -> std::result::Result<HttpResponse, Error> {
debug!("Starting github callback");
let query = req.query_string();
let config = SigninFlowHandleCallbackByQueryConfiguration::new();
let ret = flow.handle_callback_by_query(query, config).await;
use oauth2_signin::web_app::SigninFlowHandleCallbackRet as R;
let response = match ret {
R::Ok((access_token_body, user_info)) => {
let mut tx = db.begin().await.map_err(|_| Error::DatabaseError)?;
match handle_user_info(
provider,
req,
&mut tx,
access_token_body.id_token,
user_info,
event_bus,
session,
)
.await
{
Ok(v) => {
tx.commit().await.map_err(|e| {
error!("Failed to commit social_auth changes to postgres: {e}");
Error::DatabaseError
})?;
v
}
Err(e) => {
tx.rollback().await.map_err(|e| {
error!("Failed to rollback social_auth changes to postgres: {e}");
Error::DatabaseError
})?;
return Err(e.into());
}
}
}
R::OkButUserInfoNone(access_token_body) => {
warn!("handle callback from {provider}");
let _id = access_token_body.id_token;
HttpResponse::FailedDependency().json(JsonError::new(
"Authentication provider did not return user info",
))
}
R::OkButUserInfoObtainError((access_token_body, error)) => {
warn!("handle callback from {provider}: {error}");
let _id = access_token_body.id_token;
HttpResponse::FailedDependency().json(JsonError::new(
"Authentication provider refused to returns user info",
))
}
R::OkButUserInfoEndpointExecuteError((access_token_body, error)) => {
warn!("handle callback from {provider}: {error}");
let _id = access_token_body.id_token;
HttpResponse::FailedDependency().json(JsonError::new(
"Authentication provider refused to returns user info",
))
}
R::FlowHandleCallbackError(error) => {
warn!("handle callback from {provider} failed: {error}");
HttpResponse::FailedDependency()
.json(JsonError::new("Authentication provider returns an error"))
}
};
Ok(response)
}
async fn handle_user_info(
provider: &str,
req: HttpRequest,
db: &mut DatabaseTransaction,
id_token: Option<String>,
user_info: UserInfo,
event_bus: Data<crate::EventBusClient>,
session: Data<actix_jwt_session::SessionStorage>,
) -> std::result::Result<HttpResponse, AuthError> {
let UserInfo {
uid: _,
name: _,
email,
raw: _,
} = user_info;
let Some(email) = email else {
return Ok(HttpResponse::BadRequest().json(JsonError::new(
"Something went wrong. Please try again later or contact the support team.",
)));
};
if !email.contains('@') {
return Ok(HttpResponse::BadRequest().json(JsonError::new(
"Something went wrong. Please try again later or contact the support team.",
)));
}
let user = Users::find()
.filter(UserColumn::Email.eq(&email))
.one(&mut *db)
.await;
let mut user: UserModel = match user {
Ok(Some(user)) => user.into(),
Ok(None) => {
return Ok(HttpResponse::BadRequest().json(JsonError::new(
"Something went wrong. Please try again later or contact the support team.",
)));
}
Err(e) => {
error!("Failed to find user for oauth {provider} sign-in: {e}");
return Ok(HttpResponse::InternalServerError().finish());
}
};
let ip = req
.peer_addr()
.ok_or(AuthError::NoPeerAddr)?
.ip()
.to_string();
let user_agent = req
.headers()
.get(USER_AGENT)
.ok_or(AuthError::NoUserAgent)?
.to_str()
.map_err(|_| AuthError::InvalidUserAgent)?
.to_string();
user.is_active = Set(true);
user.last_active = Set(Some(chrono::Utc::now().fixed_offset()));
user.last_login_time = Set(Some(chrono::Utc::now().fixed_offset()));
user.last_login_ip = Set(ip.clone());
user.last_login_uagent = Set(user_agent.clone());
user.last_login_medium = Set(SignInMedium::OAuth.to_string());
user.is_email_verified = Set(true);
let user_id = user.id.clone().unwrap();
let user = match user.update(&mut *db).await {
Ok(user) => user,
Err(e) => {
error!("Failed to update user {user_id:?} on oauth {provider}: {e}");
return Ok(HttpResponse::InternalServerError().finish());
}
};
let workspace_invites = WorkspaceMemberInvites::find()
.filter(
WorkspaceMemberInviteColumn::Accepted
.eq(true)
.and(WorkspaceMemberInviteColumn::Email.eq(&email)),
)
.all(&mut *db)
.await
.map_err(|e| {
error!("Failed to update user {user_id:?} on oauth {provider}: {e}");
OAuthError::FetchWorkspaceInvites {
user_id,
provider: provider.to_owned(),
}
})?;
let project_invites = ProjectMemberInvites::find()
.filter(
ProjectMemberInviteColumn::Accepted
.eq(true)
.and(ProjectMemberInviteColumn::Email.eq(&email)),
)
.all(&mut *db)
.await
.map_err(|e| {
error!("Failed to update user {user_id:?} on oauth {provider}: {e}");
OAuthError::FetchProjectInvites {
user_id,
provider: provider.to_owned(),
}
})?;
// Create workspace members
let insert = WorkspaceMembers::insert_many(workspace_invites.iter().map(
|WorkspaceMemberInvite {
role,
created_by_id,
workspace_id,
..
}| WorkspaceMemberModel {
id: NotSet,
created_at: Set(Utc::now().fixed_offset()),
updated_at: Set(Utc::now().fixed_offset()),
role: Set(match role {
Roles::Admin => Roles::Member,
_ => role.clone(),
}),
created_by_id: Set(created_by_id.clone()),
member_id: Set(user_id),
updated_by_id: NotSet,
workspace_id: Set(workspace_id.clone()),
company_role: NotSet,
view_props: NotSet,
default_props: NotSet,
issue_props: NotSet,
is_active: Set(true),
},
));
if let Err(e) = insert
.on_conflict(OnConflict::new().do_nothing().to_owned())
.exec(&mut *db)
.await
{
error!("Failed to add user {user_id:?} to workspace with provider {provider}: {e}");
return Ok(HttpResponse::InternalServerError()
.json(JsonError::new("Failed to add user to workspaces")));
}
// Create project members
let insert = ProjectMembers::insert_many(project_invites.iter().map(
|ProjectMemberInvite {
role,
created_by_id,
workspace_id,
..
}| ProjectMemberModel {
id: NotSet,
created_at: Set(Utc::now().fixed_offset()),
updated_at: Set(Utc::now().fixed_offset()),
role: Set(match role {
ProjectMemberRoles::Admin => ProjectMemberRoles::Member,
_ => role.clone(),
}),
created_by_id: Set(created_by_id.clone()),
member_id: Set(Some(user_id)),
updated_by_id: NotSet,
workspace_id: Set(workspace_id.clone()),
view_props: NotSet,
default_props: NotSet,
is_active: Set(true),
comment: Set(None),
project_id: NotSet,
sort_order: NotSet,
preferences: NotSet,
},
));
if let Err(e) = insert
.on_conflict(OnConflict::new().do_nothing().to_owned())
.exec(&mut *db)
.await
{
error!("Failed to add user {user_id:?} to project with provider {provider}: {e}");
return Ok(HttpResponse::InternalServerError()
.json(JsonError::new("Failed to add user to projects")));
}
// cleanups
if let Err(e) = WorkspaceMemberInvites::delete_many()
.filter(
WorkspaceMemberInviteColumn::Id.is_in(
workspace_invites
.into_iter()
.map(|w| uuid::Uuid::from_u128(w.id.as_u128()))
.collect::<Vec<_>>(),
),
)
.exec(&mut *db)
.await
{
error!(
"Failed clean up workspace invites for user {user_id:?} with provider {provider}: {e}"
);
};
if let Err(e) = ProjectMemberInvites::delete_many()
.filter(
ProjectMemberInviteColumn::Id.is_in(
project_invites
.into_iter()
.map(|w| uuid::Uuid::from_u128(w.id.as_u128()))
.collect::<Vec<_>>(),
),
)
.exec(&mut *db)
.await
{
error!(
"Failed clean up project invites for user {user_id:?} with provider {provider}: {e}"
);
};
entities::social_login_connections::ActiveModel {
id: NotSet,
created_at: NotSet,
updated_at: NotSet,
medium: Set(provider.to_owned()),
last_login_at: Set(Some(chrono::Utc::now().fixed_offset())),
last_received_at: NotSet,
token_data: Set(Some(serde_json::json!({ "id_token": id_token }))),
extra_data: NotSet,
created_by_id: Set(Some(user_id)),
updated_by_id: NotSet,
user_id: Set(user_id),
}
.save(&mut *db)
.await
.map_err(|e| {
error!("Failed to create social media connection {provider:?}: {e}");
OAuthError::ConnectSocialMedia {
user_id,
provider: provider.to_owned(),
}
})?;
if let Err(e) = event_bus
.publish(
Topic::User,
Msg::User(UserMsg::SignIn {
user_id: UserId::new(user_id),
email,
user_agent,
ip,
medium: SignInMedium::OAuth,
first_time: false,
}),
rumqttc::QoS::AtLeastOnce,
true,
)
.await
{
warn!("Failed to publish sign-in msg after {provider} callback: {e}");
};
let access_ttl = JwtTtl(actix_jwt_session::Duration::days(9_999));
let refresh_ttl = RefreshTtl(actix_jwt_session::Duration::days(9_999));
let claims = AppClaims::from_user(user, access_ttl);
let pair = match session.store(claims, access_ttl, refresh_ttl).await {
Ok(pair) => pair,
Err(e) => {
error!("Failed to store session: {e}");
return Ok(HttpResponse::InternalServerError().finish());
}
};
let access_token = match pair.jwt.encode() {
Ok(s) => s,
Err(e) => {
error!("Failed to store session: {e}");
return Ok(HttpResponse::InternalServerError().finish());
}
};
let refresh_token = match pair.refresh.encode() {
Ok(s) => s,
Err(e) => {
error!("Failed to store session: {e}");
return Ok(HttpResponse::InternalServerError().finish());
}
};
#[derive(Debug, serde::Serialize)]
struct Response {
access_token: String,
refresh_token: String,
}
Ok(HttpResponse::Created()
.append_header((JWT_HEADER_NAME, access_token.clone()))
.append_header((REFRESH_HEADER_NAME, refresh_token.clone()))
.json(Response {
access_token,
refresh_token,
}))
}
fn github_flow(
client: IsahcClient,
client_id: String,
client_secret: String,
uri: &str,
) -> SigninFlow<IsahcClient> {
SigninFlow::new(
client.clone(),
GithubProviderWithWebApplication::new(
client_id.to_owned(),
client_secret.to_owned(),
format!("{uri}/api/auth/github/callback").parse().unwrap(),
)
.unwrap(),
vec![GithubScope::PublicRepo, GithubScope::UserEmail],
GithubExtensionsBuilder,
)
}
fn gitlab_flow(
client: IsahcClient,
client_id: String,
client_secret: String,
uri: &str,
) -> SigninFlow<IsahcClient> {
SigninFlow::new(
client.clone(),
GitlabProviderForEndUsers::new(
BASE_URL_GITLAB_COM,
client_id.to_owned(),
client_secret.to_owned(),
oauth2_core::types::RedirectUri::Url(
format!("{uri}/api/auth/gitlab/callback").parse().unwrap(),
),
)
.unwrap(),
vec![
GitlabScope::Openid,
GitlabScope::Profile,
GitlabScope::Email,
GitlabScope::ReadUser,
],
GitlabExtensionsBuilder,
)
}
fn google_flow(
client: IsahcClient,
client_id: String,
client_secret: String,
uri: &str,
) -> SigninFlow<IsahcClient> {
SigninFlow::new(
client.clone(),
GoogleProviderForWebServerApps::new(
client_id.clone(),
client_secret.clone(),
oauth2_core::types::RedirectUri::Url(
format!("{uri}/api/auth/google/callback").parse().unwrap(),
),
)
.unwrap()
.configure(|x| {
x.access_type = Some(GoogleProviderForWebServerAppsAccessType::Offline);
x.include_granted_scopes = Some(true);
}),
vec![
GoogleScope::Email,
GoogleScope::Profile,
GoogleScope::Openid,
],
GoogleExtensionsBuilder,
)
}
fn amazon_flow(
client: IsahcClient,
client_id: String,
client_secret: String,
uri: &str,
) -> SigninFlow<IsahcClient> {
SigninFlow::new(
client,
AmazonProviderWithWebServices::new(
client_id.to_owned(),
client_secret.to_owned(),
oauth2_core::types::RedirectUri::Url(
format!("{uri}/api/auth/amazon/callback").parse().unwrap(),
),
AmazonTokenUrlRegion::NA,
)
.unwrap(),
vec![AmazonScope::Profile, AmazonScope::PostalCode],
AmazonExtensionsBuilder,
)
}

View File

@ -3,15 +3,13 @@ pub use authentication::*;
mod authentication;
mod config;
mod social_auth;
mod users;
pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) {
config.service(
scope("/api")
.configure(config::configure)
.configure(users::configure)
.configure(authentication::configure)
.configure(|c| social_auth::configure(http_client, c)),
.configure(users::configure),
);
authentication::configure(http_client, config);
}

View File

@ -1,192 +0,0 @@
use std::env::var as env_var;
use actix_web::{
get,
web::{Data, Query, ServiceConfig},
HttpResponse,
};
use derive_more::Deref;
use http_api_isahc_client::IsahcClient;
use oauth2_amazon::{
AmazonExtensionsBuilder, AmazonProviderWithWebServices, AmazonScope, AmazonTokenUrlRegion,
};
use oauth2_github::{GithubExtensionsBuilder, GithubProviderWithWebApplication, GithubScope};
use oauth2_gitlab::{
GitlabExtensionsBuilder, GitlabProviderForEndUsers, GitlabScope, BASE_URL_GITLAB_COM,
};
use oauth2_google::{
GoogleExtensionsBuilder, GoogleProviderForWebServerApps,
GoogleProviderForWebServerAppsAccessType, GoogleScope,
};
use oauth2_signin::web_app::{
SigninFlow,
SigninFlowHandleCallbackByQueryConfiguration,
};
use tracing::{debug, info};
macro_rules! oauth_envs {
($env: expr, 2) => {{
use std::concat;
use std::env::var;
var(concat!($env, "_CLIENT_ID"))
.ok()
.zip(var(concat!($env, "_CLIENT_SECRET")).ok())
}};
($env: expr, 1) => {{
use std::concat;
use std::env::var;
var(concat!($env, "_CLIENT_ID")).ok()
}};
}
#[derive(Debug, Deref)]
struct Gitlab(SigninFlow<IsahcClient>);
#[derive(Debug, Deref)]
struct Github(SigninFlow<IsahcClient>);
#[derive(Debug, Deref)]
struct Google(SigninFlow<IsahcClient>);
#[derive(Debug, Deref)]
struct Amazon(SigninFlow<IsahcClient>);
struct OAuthSecrets {
client_id: String,
client_secret: Option<String>,
}
impl OAuthSecrets {
pub fn new1(client_id: String) -> Self {
Self {
client_secret: None,
client_id,
}
}
pub fn new2(client_id: String, client_secret: String) -> Self {
Self {
client_secret: Some(client_secret),
client_id,
}
}
}
pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) {
let schema = env_var("JET_API_SCHEMA").unwrap();
let host = env_var("JET_API_HOST").unwrap();
let uri = format!("{schema}://{host}");
let client = http_api_isahc_client::IsahcClient::new().unwrap();
if let Some((client_id, client_secret)) = oauth_envs!("GITHUB", 2) {
config
.app_data(Data::new(Github(SigninFlow::new(
client.clone(),
GithubProviderWithWebApplication::new(
client_id.to_owned(),
client_secret.to_owned(),
format!("{uri}/auth/github/callback").parse().unwrap(),
)
.unwrap(),
vec![GithubScope::PublicRepo, GithubScope::UserEmail],
GithubExtensionsBuilder,
))))
.service(github_callback);
}
if let Some((client_id, client_secret)) = oauth_envs!("GITLAB", 2) {
config
.app_data(Data::new(Gitlab(SigninFlow::new(
client.clone(),
GitlabProviderForEndUsers::new(
BASE_URL_GITLAB_COM,
client_id.to_owned(),
client_secret.to_owned(),
oauth2_core::types::RedirectUri::Url(
format!("{uri}/auth/gitlab/callback").parse().unwrap(),
),
)
.unwrap(),
vec![
GitlabScope::Openid,
GitlabScope::Profile,
GitlabScope::Email,
GitlabScope::ReadUser,
],
GitlabExtensionsBuilder,
))))
.service(gitlab_callback);
}
if let Some((client_id, client_secret)) = oauth_envs!("GOOGLE", 2) {
config
.app_data(Data::new(Google(SigninFlow::new(
client.clone(),
GoogleProviderForWebServerApps::new(
client_id.clone(),
client_secret.clone(),
oauth2_core::types::RedirectUri::Url(
format!("{uri}/auth/google/callback").parse().unwrap(),
),
)
.unwrap()
.configure(|x| {
x.access_type = Some(GoogleProviderForWebServerAppsAccessType::Offline);
x.include_granted_scopes = Some(true);
}),
vec![
GoogleScope::Email,
GoogleScope::Profile,
GoogleScope::Openid,
],
GoogleExtensionsBuilder,
))))
.service(google_callback);
}
if let Some((client_id, client_secret)) = oauth_envs!("AMAZON", 2) {
config
.app_data(Data::new(Amazon(SigninFlow::new(
client.clone(),
AmazonProviderWithWebServices::new(
client_id.to_owned(),
client_secret.to_owned(),
oauth2_core::types::RedirectUri::Url(
format!("{uri}/auth/amazon/callback").parse().unwrap(),
),
AmazonTokenUrlRegion::NA,
)
.unwrap(),
vec![AmazonScope::Profile, AmazonScope::PostalCode],
AmazonExtensionsBuilder,
))))
.service(amazon_callback);
}
config.service(social_auth);
}
#[get("/social-auth")]
async fn social_auth() -> HttpResponse {
HttpResponse::NotImplemented().finish()
}
#[get("/auth/gitlab/callback")]
async fn gitlab_callback(flow: Data<Gitlab>, query: Query<String>) -> HttpResponse {
let query = query.into_inner();
debug!("gitlab callback {query:?}");
let config = SigninFlowHandleCallbackByQueryConfiguration::new();
let ret = flow.handle_callback_by_query(query, config).await;
info!("gitlab {ret:?}");
HttpResponse::NotImplemented().finish()
}
#[get("/auth/github/callback")]
async fn github_callback(flow: Data<Github>) -> HttpResponse {
HttpResponse::NotImplemented().finish()
}
#[get("/auth/google/callback")]
async fn google_callback(flow: Data<Google>) -> HttpResponse {
HttpResponse::NotImplemented().finish()
}
#[get("/auth/amazon/callback")]
async fn amazon_callback(flow: Data<Amazon>) -> HttpResponse {
HttpResponse::NotImplemented().finish()
}

View File

@ -17,6 +17,8 @@ pub const APPLICATION_NAME: &str = "jet-api";
#[actix_web::main]
async fn main() {
#[cfg(debug_assertions)]
dotenv::from_filename(".env.development").ok();
tracing_subscriber::fmt::init();
let addr = env::var("JET_API_ADDR").unwrap_or_else(|_| "0.0.0.0:7865".to_owned());

View File

@ -16,7 +16,7 @@ impl JsonError {
}
}
#[derive(Debug, Copy, Clone, derive_more::Display)]
#[derive(Debug, Clone, derive_more::Display)]
pub enum Error {
#[display(fmt = "Database connection error")]
DatabaseError,

View File

@ -1,3 +1,7 @@
use std::ops::Add;
use actix_jwt_session::JwtTtl;
use entities::users::Model as User;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -11,9 +15,9 @@ pub enum Audience {
#[serde(rename_all = "snake_case")]
pub struct AppClaims {
#[serde(rename = "exp")]
pub expiration_time: u64,
pub expiration_time: i64,
#[serde(rename = "iat")]
pub issues_at: usize,
pub issued_at: i64,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
@ -24,7 +28,7 @@ pub struct AppClaims {
#[serde(rename = "aci")]
pub account_id: Uuid,
#[serde(rename = "nbf")]
pub not_before: u64,
pub not_before: i64,
}
impl actix_jwt_session::Claims for AppClaims {
@ -41,4 +45,19 @@ impl AppClaims {
pub fn account_id(&self) -> Uuid {
self.account_id
}
pub fn from_user(user: User, ttl: JwtTtl) -> Self {
let ttl = ttl.0;
let ttl = chrono::Duration::seconds(ttl.whole_seconds());
let now = chrono::Utc::now();
Self {
expiration_time: now.naive_utc().add(ttl).timestamp(),
issued_at: now.timestamp(),
subject: user.email.clone().unwrap_or_default(),
audience: Audience::Web,
jwt_id: Uuid::new_v4(),
account_id: user.id,
not_before: now.timestamp(),
}
}
}

View File

@ -56,12 +56,15 @@ pub enum FileMsg {
pub enum SignInMedium {
#[display(fmt = "MAGIC_LINK")]
MagicLink,
#[display(fmt = "oauth")]
OAuth,
}
impl SignInMedium {
pub fn as_str(self) -> &'static str {
match self {
Self::MagicLink => "MAGIC_LINK",
Self::OAuth => "oauth",
}
}
}

File diff suppressed because it is too large Load Diff