397 lines
13 KiB
Rust
397 lines
13 KiB
Rust
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose;
|
|
use chrono::Utc;
|
|
use models::users::{user_activity_log, user_ssh_key};
|
|
use sea_orm::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use sha2::{Digest, Sha256};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct AddSshKeyParams {
|
|
pub title: String,
|
|
|
|
pub public_key: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct UpdateSshKeyParams {
|
|
pub title: Option<String>,
|
|
|
|
pub expires_at: Option<chrono::DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct SshKeyResponse {
|
|
pub id: i64,
|
|
pub user_uid: Uuid,
|
|
pub title: String,
|
|
pub fingerprint: String,
|
|
pub key_type: String,
|
|
pub key_bits: Option<i32>,
|
|
pub is_verified: bool,
|
|
pub last_used_at: Option<chrono::DateTime<Utc>>,
|
|
pub expires_at: Option<chrono::DateTime<Utc>>,
|
|
pub is_revoked: bool,
|
|
pub created_at: chrono::DateTime<Utc>,
|
|
pub updated_at: chrono::DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct SshKeyListResponse {
|
|
pub keys: Vec<SshKeyResponse>,
|
|
pub total: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ParsedSshKey {
|
|
pub key_type: String,
|
|
pub key_data: Vec<u8>,
|
|
pub key_bits: Option<i32>,
|
|
pub comment: Option<String>,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn user_add_ssh_key(
|
|
&self,
|
|
context: &Session,
|
|
params: AddSshKeyParams,
|
|
) -> Result<SshKeyResponse, AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let public_key = params.public_key.trim().to_string();
|
|
|
|
let parsed = self.user_parse_ssh_public_key(&public_key)?;
|
|
|
|
let fingerprint = self.user_generate_ssh_fingerprint(&parsed.key_data)?;
|
|
|
|
let existing: Option<user_ssh_key::Model> = user_ssh_key::Entity::find()
|
|
.filter(user_ssh_key::Column::Fingerprint.eq(&fingerprint))
|
|
.one(&self.db)
|
|
.await?;
|
|
|
|
if existing.is_some() {
|
|
return Err(AppError::BadRequest("SSH key already exists".to_string()));
|
|
}
|
|
|
|
let count = user_ssh_key::Entity::find()
|
|
.filter(user_ssh_key::Column::User.eq(user_uid))
|
|
.filter(user_ssh_key::Column::IsRevoked.eq(false))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
if count >= 50 {
|
|
return Err(AppError::BadRequest("Too many SSH keys".to_string()));
|
|
}
|
|
|
|
let now = Utc::now();
|
|
let ssh_key_model = user_ssh_key::ActiveModel {
|
|
user: Set(user_uid),
|
|
title: Set(params.title.clone()),
|
|
public_key: Set(public_key),
|
|
fingerprint: Set(fingerprint),
|
|
key_type: Set(parsed.key_type.clone()),
|
|
key_bits: Set(parsed.key_bits),
|
|
is_verified: Set(false),
|
|
last_used_at: Set(None),
|
|
expires_at: Set(None),
|
|
is_revoked: Set(false),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
..Default::default()
|
|
};
|
|
|
|
let created_key = ssh_key_model.insert(&self.db).await?;
|
|
|
|
let _ = user_activity_log::ActiveModel {
|
|
user_uid: Set(Some(user_uid)),
|
|
action: Set("ssh_key_add".to_string()),
|
|
ip_address: Set(context.ip_address()),
|
|
user_agent: Set(context.user_agent()),
|
|
details: Set(serde_json::json!({
|
|
"key_id": created_key.id,
|
|
"key_title": params.title,
|
|
"key_type": parsed.key_type,
|
|
"fingerprint": created_key.fingerprint
|
|
})),
|
|
created_at: Set(now),
|
|
..Default::default()
|
|
}
|
|
.insert(&self.db)
|
|
.await;
|
|
|
|
Ok(self.user_model_to_response(created_key))
|
|
}
|
|
|
|
pub async fn user_list_ssh_keys(
|
|
&self,
|
|
context: &Session,
|
|
) -> Result<SshKeyListResponse, AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let keys: Vec<user_ssh_key::Model> = user_ssh_key::Entity::find()
|
|
.filter(user_ssh_key::Column::User.eq(user_uid))
|
|
.order_by_desc(user_ssh_key::Column::CreatedAt)
|
|
.all(&self.db)
|
|
.await?;
|
|
|
|
let total = keys.len();
|
|
let keys = keys
|
|
.into_iter()
|
|
.map(|k| self.user_model_to_response(k))
|
|
.collect();
|
|
|
|
Ok(SshKeyListResponse { keys, total })
|
|
}
|
|
|
|
pub async fn user_get_ssh_key(
|
|
&self,
|
|
context: &Session,
|
|
key_id: i64,
|
|
) -> Result<SshKeyResponse, AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let key: user_ssh_key::Model = user_ssh_key::Entity::find_by_id(key_id)
|
|
.filter(user_ssh_key::Column::User.eq(user_uid))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("SSH key not found".to_string()))?;
|
|
|
|
Ok(self.user_model_to_response(key))
|
|
}
|
|
|
|
pub async fn user_update_ssh_key(
|
|
&self,
|
|
context: &Session,
|
|
key_id: i64,
|
|
params: UpdateSshKeyParams,
|
|
) -> Result<SshKeyResponse, AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let key: user_ssh_key::Model = user_ssh_key::Entity::find_by_id(key_id)
|
|
.filter(user_ssh_key::Column::User.eq(user_uid))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("SSH key not found".to_string()))?;
|
|
|
|
let mut active_key: user_ssh_key::ActiveModel = key.into();
|
|
|
|
let updated_title = params.title.clone();
|
|
let updated_expires_at = params.expires_at;
|
|
|
|
if let Some(title) = params.title {
|
|
active_key.title = Set(title);
|
|
}
|
|
|
|
if let Some(expires_at) = params.expires_at {
|
|
active_key.expires_at = Set(Some(expires_at));
|
|
}
|
|
|
|
active_key.updated_at = Set(Utc::now());
|
|
|
|
let updated_key = active_key.update(&self.db).await?;
|
|
|
|
let _ = user_activity_log::ActiveModel {
|
|
user_uid: Set(Some(user_uid)),
|
|
action: Set("ssh_key_update".to_string()),
|
|
ip_address: Set(context.ip_address()),
|
|
user_agent: Set(context.user_agent()),
|
|
details: Set(serde_json::json!({
|
|
"key_id": key_id,
|
|
"updated_fields": {
|
|
"title": updated_title,
|
|
"expires_at": updated_expires_at
|
|
}
|
|
})),
|
|
created_at: Set(Utc::now()),
|
|
..Default::default()
|
|
}
|
|
.insert(&self.db)
|
|
.await;
|
|
|
|
Ok(self.user_model_to_response(updated_key))
|
|
}
|
|
|
|
pub async fn user_delete_ssh_key(
|
|
&self,
|
|
context: &Session,
|
|
key_id: i64,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let key: user_ssh_key::Model = user_ssh_key::Entity::find_by_id(key_id)
|
|
.filter(user_ssh_key::Column::User.eq(user_uid))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("SSH key not found".to_string()))?;
|
|
let _ = user_activity_log::ActiveModel {
|
|
user_uid: Set(Some(user_uid)),
|
|
action: Set("ssh_key_delete".to_string()),
|
|
ip_address: Set(context.ip_address()),
|
|
user_agent: Set(context.user_agent()),
|
|
details: Set(serde_json::json!({
|
|
"key_id": key_id,
|
|
"key_title": key.title.clone(),
|
|
"fingerprint": key.fingerprint.clone()
|
|
})),
|
|
created_at: Set(Utc::now()),
|
|
..Default::default()
|
|
}
|
|
.insert(&self.db)
|
|
.await;
|
|
|
|
user_ssh_key::Entity::delete(key.into_active_model())
|
|
.exec(&self.db)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn user_revoke_ssh_key(
|
|
&self,
|
|
context: &Session,
|
|
key_id: i64,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
|
|
let key: user_ssh_key::Model = user_ssh_key::Entity::find_by_id(key_id)
|
|
.filter(user_ssh_key::Column::User.eq(user_uid))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("SSH key not found".to_string()))?;
|
|
|
|
let mut active_key: user_ssh_key::ActiveModel = key.clone().into();
|
|
active_key.is_revoked = Set(true);
|
|
active_key.updated_at = Set(Utc::now());
|
|
|
|
active_key.update(&self.db).await?;
|
|
|
|
let _ = user_activity_log::ActiveModel {
|
|
user_uid: Set(Some(user_uid)),
|
|
action: Set("ssh_key_revoke".to_string()),
|
|
ip_address: Set(context.ip_address()),
|
|
user_agent: Set(context.user_agent()),
|
|
details: Set(serde_json::json!({
|
|
"key_id": key_id,
|
|
"key_title": key.title,
|
|
"fingerprint": key.fingerprint
|
|
})),
|
|
created_at: Set(Utc::now()),
|
|
..Default::default()
|
|
}
|
|
.insert(&self.db)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn user_verify_ssh_key_by_fingerprint(
|
|
&self,
|
|
fingerprint: &str,
|
|
) -> Result<user_ssh_key::Model, AppError> {
|
|
let key: user_ssh_key::Model = user_ssh_key::Entity::find()
|
|
.filter(user_ssh_key::Column::Fingerprint.eq(fingerprint))
|
|
.filter(user_ssh_key::Column::IsRevoked.eq(false))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("SSH key not found".to_string()))?;
|
|
|
|
if let Some(expires_at) = key.expires_at {
|
|
let now = Utc::now();
|
|
if now > expires_at {
|
|
return Err(AppError::Unauthorized); // AppError::SshKeyExpired replaced with generic Unauthorized
|
|
}
|
|
}
|
|
|
|
Ok(key)
|
|
}
|
|
|
|
pub async fn user_touch_ssh_key_last_used(&self, key_id: i64) -> Result<(), AppError> {
|
|
let key: user_ssh_key::Model = user_ssh_key::Entity::find_by_id(key_id)
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("SSH key not found".to_string()))?;
|
|
|
|
let mut active_key: user_ssh_key::ActiveModel = key.into();
|
|
active_key.last_used_at = Set(Some(Utc::now()));
|
|
active_key.updated_at = Set(Utc::now());
|
|
|
|
active_key.update(&self.db).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn user_parse_ssh_public_key(&self, public_key: &str) -> Result<ParsedSshKey, AppError> {
|
|
let parts: Vec<&str> = public_key.split_whitespace().collect();
|
|
if parts.len() < 2 {
|
|
return Err(AppError::BadRequest("Invalid SSH key format".to_string()));
|
|
}
|
|
let key_type = parts[0];
|
|
let key_data_base64 = parts[1];
|
|
let comment: Option<String> = parts.get(2).map(|s| (*s).to_string());
|
|
let normalized_key_type = match key_type {
|
|
"ssh-rsa" => "rsa",
|
|
"ssh-ed25519" => "ed25519",
|
|
"ecdsa-sha2-nistp256" | "ecdsa-sha2-nistp384" | "ecdsa-sha2-nistp521" => "ecdsa",
|
|
"ssh-dss" => "dsa",
|
|
_ => return Err(AppError::BadRequest("Unsupported SSH key type".to_string())),
|
|
};
|
|
let key_data = general_purpose::STANDARD
|
|
.decode(key_data_base64)
|
|
.map_err(|_| AppError::BadRequest("Invalid SSH key format".to_string()))?;
|
|
let key_bits = if normalized_key_type == "rsa" {
|
|
self.user_calculate_rsa_key_bits(&key_data)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(ParsedSshKey {
|
|
key_type: normalized_key_type.to_string(),
|
|
key_data,
|
|
key_bits,
|
|
comment,
|
|
})
|
|
}
|
|
|
|
fn user_generate_ssh_fingerprint(&self, key_data: &[u8]) -> Result<String, AppError> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(key_data);
|
|
let hash = hasher.finalize();
|
|
let fingerprint = format!("SHA256:{}", general_purpose::STANDARD_NO_PAD.encode(&hash));
|
|
|
|
Ok(fingerprint)
|
|
}
|
|
|
|
fn user_calculate_rsa_key_bits(&self, key_data: &[u8]) -> Option<i32> {
|
|
if key_data.len() < 256 {
|
|
Some(1024)
|
|
} else if key_data.len() < 512 {
|
|
Some(2048)
|
|
} else if key_data.len() < 1024 {
|
|
Some(4096)
|
|
} else {
|
|
Some(8192)
|
|
}
|
|
}
|
|
|
|
fn user_model_to_response(&self, model: user_ssh_key::Model) -> SshKeyResponse {
|
|
SshKeyResponse {
|
|
id: model.id,
|
|
user_uid: model.user,
|
|
title: model.title,
|
|
fingerprint: model.fingerprint,
|
|
key_type: model.key_type,
|
|
key_bits: model.key_bits,
|
|
is_verified: model.is_verified,
|
|
last_used_at: model.last_used_at,
|
|
expires_at: model.expires_at,
|
|
is_revoked: model.is_revoked,
|
|
created_at: model.created_at,
|
|
updated_at: model.updated_at,
|
|
}
|
|
}
|
|
}
|