326 lines
9.8 KiB
Rust
326 lines
9.8 KiB
Rust
use base64::{Engine as _, engine::general_purpose};
|
|
use db::{database::AppDatabase, sqlx};
|
|
use model::{
|
|
repos::{RepoHistoryNameModel, RepoModel},
|
|
users::{UserModel, UserSshKeyModel},
|
|
workspace::{WkHistoryNameModel, WkMemberModel, WorkspaceModel},
|
|
};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::errors::GitError;
|
|
|
|
pub struct SshAuthService {
|
|
db: AppDatabase,
|
|
}
|
|
|
|
pub struct SshKeyUser {
|
|
pub user: UserModel,
|
|
pub key_id: i64,
|
|
pub key_title: String,
|
|
}
|
|
|
|
impl SshAuthService {
|
|
pub fn new(db: AppDatabase) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
pub async fn find_repo(
|
|
&self,
|
|
namespace: &str,
|
|
repo_name: &str,
|
|
) -> Result<RepoModel, GitError> {
|
|
let namespace = self.find_namespace(namespace).await?;
|
|
self.find_repository_by_name_and_wk(repo_name, namespace.id)
|
|
.await
|
|
}
|
|
|
|
async fn find_namespace(
|
|
&self,
|
|
namespace: &str,
|
|
) -> Result<WorkspaceModel, GitError> {
|
|
let workspace = sqlx::query_as::<_, WorkspaceModel>(
|
|
"SELECT id, name, description, avatar_url, created_at FROM workspace WHERE name = $1",
|
|
)
|
|
.bind(namespace)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
if let Some(ws) = workspace {
|
|
return Ok(ws);
|
|
}
|
|
|
|
let history = sqlx::query_as::<_, WkHistoryNameModel>(
|
|
"SELECT id, wk, name, changed_by, created_at FROM wk_history_name WHERE name = $1",
|
|
)
|
|
.bind(namespace)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
if let Some(history) = history {
|
|
let ws = sqlx::query_as::<_, WorkspaceModel>(
|
|
"SELECT id, name, description, avatar_url, created_at FROM workspace WHERE id = $1",
|
|
)
|
|
.bind(history.wk)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
if let Some(ws) = ws {
|
|
return Ok(ws);
|
|
}
|
|
}
|
|
|
|
Err(GitError::NotFound("Workspace not found".to_string()))
|
|
}
|
|
|
|
async fn find_repository_by_name_and_wk(
|
|
&self,
|
|
repo_name: &str,
|
|
wk_id: uuid::Uuid,
|
|
) -> Result<RepoModel, GitError> {
|
|
let repo = sqlx::query_as::<_, RepoModel>(
|
|
"SELECT id, wk, name, description, default_branch, visibility, size_bytes, is_archived, is_template, is_mirror, created_by, storage_path, created_at, updated_at, deleted_at FROM repo WHERE name = $1 AND wk = $2",
|
|
)
|
|
.bind(repo_name)
|
|
.bind(wk_id)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
if let Some(repo) = repo {
|
|
return Ok(repo);
|
|
}
|
|
|
|
let history = sqlx::query_as::<_, RepoHistoryNameModel>(
|
|
"SELECT id, repo, name, changed_by, created_at FROM repo_history_name WHERE name = $1",
|
|
)
|
|
.bind(repo_name)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
if let Some(history) = history {
|
|
let repo = sqlx::query_as::<_, RepoModel>(
|
|
"SELECT id, wk, name, description, default_branch, visibility, size_bytes, is_archived, is_template, is_mirror, created_by, storage_path, created_at, updated_at, deleted_at FROM repo WHERE id = $1 AND wk = $2",
|
|
)
|
|
.bind(history.repo)
|
|
.bind(wk_id)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
if let Some(repo) = repo {
|
|
return Ok(repo);
|
|
}
|
|
}
|
|
|
|
Err(GitError::NotFound("Repository not found".to_string()))
|
|
}
|
|
|
|
pub async fn find_user_by_public_key(
|
|
&self,
|
|
public_key_str: &str,
|
|
) -> Result<Option<SshKeyUser>, sqlx::Error> {
|
|
let fingerprint =
|
|
match self.generate_fingerprint_from_public_key(public_key_str) {
|
|
Ok(fp) => fp,
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"failed to generate SSH key fingerprint error={}",
|
|
e
|
|
);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
let fingerprint_preview = if fingerprint.len() > 16 {
|
|
format!("{}...", &fingerprint[..16])
|
|
} else {
|
|
fingerprint.clone()
|
|
};
|
|
tracing::info!(
|
|
"looking up user with SSH key fingerprint={}",
|
|
fingerprint_preview
|
|
);
|
|
|
|
let ssh_key = sqlx::query_as::<_, UserSshKeyModel>(
|
|
"SELECT id, \"user\", title, public_key, fingerprint, key_type, key_bits, is_verified, last_used_at, expires_at, is_revoked, created_at, updated_at FROM user_ssh_key WHERE fingerprint = $1 AND is_revoked = false",
|
|
)
|
|
.bind(&fingerprint)
|
|
.fetch_optional(self.db.reader())
|
|
.await?;
|
|
|
|
let ssh_key = match ssh_key {
|
|
Some(key) => key,
|
|
None => {
|
|
tracing::warn!("no SSH key found fingerprint={}", fingerprint);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
if self.is_key_expired(&ssh_key) {
|
|
tracing::warn!(
|
|
"SSH key expired key_id={} expires_at={:?}",
|
|
ssh_key.id,
|
|
ssh_key.expires_at
|
|
);
|
|
return Ok(None);
|
|
}
|
|
|
|
let user_model = sqlx::query_as::<_, UserModel>(
|
|
"SELECT id, username, display_name, avatar_url, website_url, allow_use, can_search, last_sign_in_at, created_at, updated_at FROM \"user\" WHERE id = $1",
|
|
)
|
|
.bind(ssh_key.user)
|
|
.fetch_optional(self.db.reader())
|
|
.await?;
|
|
|
|
if let Some(user) = user_model {
|
|
tracing::info!(
|
|
"SSH key matched user={} key={}",
|
|
user.username,
|
|
ssh_key.title
|
|
);
|
|
return Ok(Some(SshKeyUser {
|
|
user,
|
|
key_id: ssh_key.id,
|
|
key_title: ssh_key.title,
|
|
}));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn is_key_expired(&self, ssh_key: &UserSshKeyModel) -> bool {
|
|
if let Some(expires_at) = ssh_key.expires_at {
|
|
let now = chrono::Utc::now();
|
|
now >= expires_at
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn update_key_last_used_async(&self, key_id: i64) {
|
|
let db_clone = self.db.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) =
|
|
Self::update_key_last_used_sync(db_clone, key_id).await
|
|
{
|
|
tracing::warn!(
|
|
"failed to update key last_used key_id={} error={}",
|
|
key_id,
|
|
e
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn update_key_last_used_sync(
|
|
db: AppDatabase,
|
|
key_id: i64,
|
|
) -> Result<(), sqlx::Error> {
|
|
let now = chrono::Utc::now();
|
|
sqlx::query("UPDATE user_ssh_key SET last_used_at = $1, updated_at = $2 WHERE id = $3")
|
|
.bind(now)
|
|
.bind(now)
|
|
.bind(key_id)
|
|
.execute(db.writer())
|
|
.await?;
|
|
|
|
tracing::info!("updated key last_used key_id={}", key_id);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn check_repo_permission(
|
|
&self,
|
|
user: &UserModel,
|
|
repo: &RepoModel,
|
|
is_write: bool,
|
|
) -> bool {
|
|
if repo.created_by == user.id {
|
|
tracing::info!(
|
|
"user is repo owner user={} repo={}",
|
|
user.username,
|
|
repo.name
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if !is_write && repo.visibility == "public" {
|
|
tracing::info!("public repo allows read access repo={}", repo.name);
|
|
return true;
|
|
}
|
|
|
|
let wk_id = repo.wk;
|
|
if self
|
|
.check_wk_member_permission(user, wk_id, is_write)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
tracing::info!(
|
|
"user has workspace member access user={} repo={}",
|
|
user.username,
|
|
repo.name
|
|
);
|
|
return true;
|
|
}
|
|
|
|
tracing::warn!(
|
|
"access denied user={} repo={} is_write={}",
|
|
user.username,
|
|
repo.name,
|
|
is_write
|
|
);
|
|
false
|
|
}
|
|
|
|
async fn check_wk_member_permission(
|
|
&self,
|
|
user: &UserModel,
|
|
wk_id: uuid::Uuid,
|
|
is_write: bool,
|
|
) -> Result<bool, sqlx::Error> {
|
|
let member = sqlx::query_as::<_, WkMemberModel>(
|
|
"SELECT wk, \"user\", owner, admin, join_at, leave_at FROM wk_member WHERE wk = $1 AND \"user\" = $2 AND leave_at IS NULL",
|
|
)
|
|
.bind(wk_id)
|
|
.bind(user.id)
|
|
.fetch_optional(self.db.reader())
|
|
.await?;
|
|
|
|
if let Some(member) = member {
|
|
if member.owner || member.admin {
|
|
return Ok(true);
|
|
}
|
|
Ok(!is_write)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
fn generate_fingerprint_from_public_key(
|
|
&self,
|
|
public_key_str: &str,
|
|
) -> Result<String, String> {
|
|
let key_data_base64 = public_key_str
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.ok_or("Invalid SSH key format")?;
|
|
|
|
let key_data = general_purpose::STANDARD
|
|
.decode(key_data_base64)
|
|
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&key_data);
|
|
let hash = hasher.finalize();
|
|
|
|
let mut fingerprint = String::with_capacity(51);
|
|
fingerprint.push_str("SHA256:");
|
|
fingerprint.push_str(&general_purpose::STANDARD_NO_PAD.encode(&hash));
|
|
|
|
Ok(fingerprint)
|
|
}
|
|
}
|