refactor: workspace secrets-core + secrets-mcp MCP SaaS
- Split library (db/crypto/service) and MCP/Web/OAuth binary - Add deploy examples and CI/docs updates Made-with: Cursor
This commit is contained in:
213
crates/secrets-core/src/service/user.rs
Normal file
213
crates/secrets-core/src/service/user.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{OauthAccount, User};
|
||||
|
||||
pub struct OAuthProfile {
|
||||
pub provider: String,
|
||||
pub provider_id: String,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Find or create a user from an OAuth profile.
|
||||
/// Returns (user, is_new) where is_new indicates first-time registration.
|
||||
pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> {
|
||||
// Check if this OAuth account already exists
|
||||
let existing: Option<OauthAccount> = sqlx::query_as(
|
||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
||||
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
||||
)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(oa) = existing {
|
||||
let user: User = sqlx::query_as(
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(oa.user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
return Ok((user, false));
|
||||
}
|
||||
|
||||
// New user — create records (no key yet; user sets passphrase on dashboard)
|
||||
let display_name = profile
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let user: User = sqlx::query_as(
|
||||
"INSERT INTO users (email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
|
||||
)
|
||||
.bind(&profile.email)
|
||||
.bind(&display_name)
|
||||
.bind(&profile.avatar_url)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.bind(&profile.email)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.avatar_url)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((user, true))
|
||||
}
|
||||
|
||||
/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
|
||||
pub async fn update_user_key_setup(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
key_salt: &[u8],
|
||||
key_check: &[u8],
|
||||
key_params: &Value,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \
|
||||
WHERE id = $4",
|
||||
)
|
||||
.bind(key_salt)
|
||||
.bind(key_check)
|
||||
.bind(key_params)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a user by ID.
|
||||
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as(
|
||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
||||
FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// List all OAuth accounts linked to a user.
|
||||
pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result<Vec<OauthAccount>> {
|
||||
let accounts = sqlx::query_as(
|
||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
||||
FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
/// Bind an additional OAuth account to an existing user.
|
||||
pub async fn bind_oauth_account(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
profile: OAuthProfile,
|
||||
) -> Result<OauthAccount> {
|
||||
// Check if this provider_id is already linked to someone else
|
||||
let conflict: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
||||
)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((existing_user_id,)) = conflict {
|
||||
if existing_user_id != user_id {
|
||||
anyhow::bail!(
|
||||
"This {} account is already linked to a different user",
|
||||
profile.provider
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"This {} account is already linked to your account",
|
||||
profile.provider
|
||||
);
|
||||
}
|
||||
|
||||
let existing_provider_for_user: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&profile.provider)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if existing_provider_for_user.is_some() {
|
||||
anyhow::bail!(
|
||||
"You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.",
|
||||
profile.provider,
|
||||
profile.provider
|
||||
);
|
||||
}
|
||||
|
||||
let account: OauthAccount = sqlx::query_as(
|
||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&profile.provider)
|
||||
.bind(&profile.provider_id)
|
||||
.bind(&profile.email)
|
||||
.bind(&profile.name)
|
||||
.bind(&profile.avatar_url)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider.
|
||||
pub async fn unbind_oauth_account(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
provider: &str,
|
||||
current_login_provider: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if current_login_provider == Some(provider) {
|
||||
anyhow::bail!(
|
||||
"Cannot unlink the {} account you are currently using to sign in",
|
||||
provider
|
||||
);
|
||||
}
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if count <= 1 {
|
||||
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first.");
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
|
||||
.bind(user_id)
|
||||
.bind(provider)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user