Skip to content
Clone
08cefb1
440 lines 15.7 KB Rust
use base64::Engine;
use db::conn::sea_orm::{ActiveModelTrait, EntityTrait, QueryFilter, Set};
use rand::TryRng;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::error::{UserError, UserResult};
use crate::features::UserService;
use crate::metrics::{self, SecurityResource, SecurityResourceOp, record_security_operation};
use super::{
AccessKeyScope, AccessKeyView, AddSshKeyCmd, CreateAccessKeyCmd, CreateAccessKeyResp,
PushPlatform, PushSubscriptionView, RecoveryCodeView, RegenerateRecoveryResp, SshKeyType,
SshKeyView, UpsertPushCmd,
};
fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::rng().try_fill_bytes(&mut buf).expect("rng");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
}
fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
let out = h.finalize();
out.iter().map(|b| format!("{:02x}", b)).collect()
}
pub async fn list_recovery(svc: &UserService, caller: Uuid) -> UserResult<Vec<RecoveryCodeView>> {
let _timer = metrics::Timer::start(metrics::FEATURE_RECOVERY_CODES_LIST);
let result: UserResult<Vec<RecoveryCodeView>> = async {
let db = svc.db.reader();
let rows = db::user::user_recovery_codes::Entity::find()
.filter(db::user::user_recovery_codes::COLUMN.user_id.eq(caller))
.all(&db)
.await?;
Ok(rows.into_iter().map(RecoveryCodeView::from).collect())
}
.await;
metrics::record_outcome(metrics::FEATURE_RECOVERY_CODES_LIST, &result);
result
}
pub async fn regenerate_recovery(
svc: &UserService,
caller: Uuid,
) -> UserResult<RegenerateRecoveryResp> {
let _timer = metrics::Timer::start(metrics::FEATURE_RECOVERY_CODES_REGENERATE);
let result: UserResult<RegenerateRecoveryResp> = async {
let writer = svc.db.writer();
db::user::user_recovery_codes::Entity::delete_many()
.filter(db::user::user_recovery_codes::COLUMN.user_id.eq(caller))
.exec(&writer)
.await?;
let max = svc.config.max_recovery_codes;
let mut codes: Vec<String> = Vec::with_capacity(max);
let now = time::OffsetDateTime::now_utc();
for _ in 0..max {
let raw = format!("{}-{}", random_token(4), random_token(4),);
let raw = raw
.replace('-', "")
.chars()
.take(10)
.collect::<String>()
.to_uppercase();
let hash = sha256_hex(raw.as_bytes());
let active: db::user::user_recovery_codes::ActiveModel =
db::user::user_recovery_codes::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_code_hash(hash)
.set_used_at(None)
.set_expires_at(None)
.set_created_at(now)
.into();
active.insert(&writer).await?;
codes.push(raw);
}
Ok(RegenerateRecoveryResp { codes })
}
.await;
metrics::record_outcome(metrics::FEATURE_RECOVERY_CODES_REGENERATE, &result);
if result.is_ok() {
record_security_operation(
SecurityResource::RecoveryCode,
SecurityResourceOp::Regenerate,
metrics::OUTCOME_SUCCESS,
);
}
result
}
pub async fn list_access_keys(svc: &UserService, caller: Uuid) -> UserResult<Vec<AccessKeyView>> {
let _timer = metrics::Timer::start(metrics::FEATURE_ACCESS_KEYS_LIST);
let result: UserResult<Vec<AccessKeyView>> = async {
let db = svc.db.reader();
let rows = db::user::user_access_keys::Entity::find()
.filter(db::user::user_access_keys::COLUMN.user_id.eq(caller))
.all(&db)
.await?;
Ok(rows.into_iter().map(AccessKeyView::from).collect())
}
.await;
metrics::record_outcome(metrics::FEATURE_ACCESS_KEYS_LIST, &result);
result
}
pub async fn create_access_key(
svc: &UserService,
caller: Uuid,
cmd: CreateAccessKeyCmd,
) -> UserResult<CreateAccessKeyResp> {
let _timer = metrics::Timer::start(metrics::FEATURE_ACCESS_KEYS_CREATE);
let result: UserResult<CreateAccessKeyResp> = async {
let bytes = svc.config.access_key_token_bytes;
let raw = random_token(bytes);
let salt = random_token(16);
let hash = sha256_hex(format!("{}{}", raw, salt).as_bytes());
let last_eight: String = raw
.chars()
.rev()
.take(8)
.collect::<String>()
.chars()
.rev()
.collect();
let now = time::OffsetDateTime::now_utc();
let writer = svc.db.writer();
let active: db::user::user_access_keys::ActiveModel =
db::user::user_access_keys::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_name(cmd.name)
.set_token_hash(hash)
.set_token_salt(salt)
.set_token_last_eight(last_eight)
.set_scope(cmd.scope)
.set_version(1)
.set_created_at(now)
.set_updated_at(now)
.into();
let saved = active.insert(&writer).await?;
Ok(CreateAccessKeyResp {
key: AccessKeyView::from(saved),
token: raw,
})
}
.await;
metrics::record_outcome(metrics::FEATURE_ACCESS_KEYS_CREATE, &result);
if result.is_ok() {
record_security_operation(
SecurityResource::AccessKey,
SecurityResourceOp::Create,
metrics::OUTCOME_SUCCESS,
);
}
result
}
pub async fn delete_access_key(svc: &UserService, caller: Uuid, key_id: Uuid) -> UserResult<()> {
let _timer = metrics::Timer::start(metrics::FEATURE_ACCESS_KEYS_DELETE);
let result: UserResult<()> = async {
let writer = svc.db.writer();
let row = db::user::user_access_keys::Entity::find_by_id(key_id)
.one(&writer)
.await?
.ok_or_else(|| UserError::NotFound(format!("access key {key_id}")))?;
if row.user_id != caller {
return Err(UserError::Forbidden(
"cannot delete other user's key".into(),
));
}
db::user::user_access_keys::Entity::delete_by_id(key_id)
.exec(&writer)
.await?;
Ok(())
}
.await;
metrics::record_outcome(metrics::FEATURE_ACCESS_KEYS_DELETE, &result);
match &result {
Ok(_) => record_security_operation(
SecurityResource::AccessKey,
SecurityResourceOp::Delete,
metrics::OUTCOME_SUCCESS,
),
Err(UserError::Forbidden(_)) => record_security_operation(
SecurityResource::AccessKey,
SecurityResourceOp::Delete,
"forbidden",
),
Err(UserError::NotFound(_)) => record_security_operation(
SecurityResource::AccessKey,
SecurityResourceOp::Delete,
"not_found",
),
Err(_) => {}
}
result
}
pub async fn list_ssh_keys(svc: &UserService, caller: Uuid) -> UserResult<Vec<SshKeyView>> {
let _timer = metrics::Timer::start(metrics::FEATURE_SSH_KEYS_LIST);
let result: UserResult<Vec<SshKeyView>> = async {
let db = svc.db.reader();
let rows = db::user::user_ssh_keys::Entity::find()
.filter(db::user::user_ssh_keys::COLUMN.user_id.eq(caller))
.all(&db)
.await?;
Ok(rows.into_iter().map(SshKeyView::from).collect())
}
.await;
metrics::record_outcome(metrics::FEATURE_SSH_KEYS_LIST, &result);
result
}
pub async fn add_ssh_key(
svc: &UserService,
caller: Uuid,
cmd: AddSshKeyCmd,
) -> UserResult<SshKeyView> {
let _timer = metrics::Timer::start(metrics::FEATURE_SSH_KEYS_ADD);
let result: UserResult<SshKeyView> = async {
if cmd.name.is_empty() || cmd.name.len() > 64 {
return Err(UserError::BadRequest(
"ssh key name length must be 1..=64".into(),
));
}
let detected = detect_ssh_key_type(&cmd.content).ok_or_else(|| {
UserError::BadRequest("ssh key content malformed or unsupported".into())
})?;
let key_type = cmd.key_type.map(Into::into).unwrap_or(detected);
let fingerprint = sha256_hex(cmd.content.as_bytes());
let writer = svc.db.writer();
let now = time::OffsetDateTime::now_utc();
let active: db::user::user_ssh_keys::ActiveModel =
db::user::user_ssh_keys::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_key_type(key_type)
.set_fingerprint(fingerprint.clone())
.set_name(cmd.name)
.set_content(cmd.content)
.set_created_at(now)
.set_updated_at(now)
.set_last_used_at(None)
.into();
let saved = active.insert(&writer).await?;
Ok(SshKeyView::from(saved))
}
.await;
metrics::record_outcome(metrics::FEATURE_SSH_KEYS_ADD, &result);
match &result {
Ok(_) => record_security_operation(
SecurityResource::SshKey,
SecurityResourceOp::Add,
metrics::OUTCOME_SUCCESS,
),
Err(UserError::BadRequest(_)) => record_security_operation(
SecurityResource::SshKey,
SecurityResourceOp::Add,
"bad_request",
),
Err(_) => {}
}
result
}
pub async fn delete_ssh_key(svc: &UserService, caller: Uuid, key_id: Uuid) -> UserResult<()> {
let _timer = metrics::Timer::start(metrics::FEATURE_SSH_KEYS_DELETE);
let result: UserResult<()> = async {
let writer = svc.db.writer();
let row = db::user::user_ssh_keys::Entity::find_by_id(key_id)
.one(&writer)
.await?
.ok_or_else(|| UserError::NotFound(format!("ssh key {key_id}")))?;
if row.user_id != caller {
return Err(UserError::Forbidden(
"cannot delete other user's ssh key".into(),
));
}
db::user::user_ssh_keys::Entity::delete_by_id(key_id)
.exec(&writer)
.await?;
Ok(())
}
.await;
metrics::record_outcome(metrics::FEATURE_SSH_KEYS_DELETE, &result);
match &result {
Ok(_) => record_security_operation(
SecurityResource::SshKey,
SecurityResourceOp::Delete,
metrics::OUTCOME_SUCCESS,
),
Err(UserError::Forbidden(_)) => record_security_operation(
SecurityResource::SshKey,
SecurityResourceOp::Delete,
"forbidden",
),
Err(UserError::NotFound(_)) => record_security_operation(
SecurityResource::SshKey,
SecurityResourceOp::Delete,
"not_found",
),
Err(_) => {}
}
result
}
fn detect_ssh_key_type(content: &str) -> Option<db::user::user_ssh_keys::SshKeyType> {
use db::user::user_ssh_keys::SshKeyType as K;
if content.starts_with("ssh-rsa") {
Some(K::SshRsa)
} else if content.starts_with("ssh-ed25519") {
Some(K::SshEd25519)
} else if content.starts_with("ecdsa-sha2-nistp256") {
Some(K::EcdsaSha2Nistp256)
} else {
None
}
}
pub async fn list_push(svc: &UserService, caller: Uuid) -> UserResult<Vec<PushSubscriptionView>> {
let _timer = metrics::Timer::start(metrics::FEATURE_PUSH_SUBS_LIST);
let result: UserResult<Vec<PushSubscriptionView>> = async {
let db = svc.db.reader();
let rows = db::user::user_push_subscriptions::Entity::find()
.filter(db::user::user_push_subscriptions::COLUMN.user_id.eq(caller))
.all(&db)
.await?;
Ok(rows.into_iter().map(PushSubscriptionView::from).collect())
}
.await;
metrics::record_outcome(metrics::FEATURE_PUSH_SUBS_LIST, &result);
result
}
pub async fn upsert_push(
svc: &UserService,
caller: Uuid,
cmd: UpsertPushCmd,
) -> UserResult<PushSubscriptionView> {
let _timer = metrics::Timer::start(metrics::FEATURE_PUSH_SUBS_UPSERT);
let result: UserResult<PushSubscriptionView> = async {
if cmd.endpoint.is_empty() || cmd.endpoint.len() > 2048 {
return Err(UserError::BadRequest("endpoint invalid".into()));
}
let writer = svc.db.writer();
let now = time::OffsetDateTime::now_utc();
let existing = db::user::user_push_subscriptions::Entity::find()
.filter(db::user::user_push_subscriptions::COLUMN.user_id.eq(caller))
.filter(
db::user::user_push_subscriptions::COLUMN
.endpoint
.eq(&cmd.endpoint),
)
.one(&writer)
.await?;
let saved = if let Some(row) = existing {
let mut am: db::user::user_push_subscriptions::ActiveModel = row.into();
am.p256dh_key = Set(cmd.p256dh_key);
am.auth_key = Set(cmd.auth_key);
am.platform = Set(cmd.platform.into());
am.user_agent = Set(cmd.user_agent);
am.updated_at = Set(Some(now));
am.update(&writer).await?
} else {
let active: db::user::user_push_subscriptions::ActiveModel =
db::user::user_push_subscriptions::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_endpoint(cmd.endpoint)
.set_p256dh_key(cmd.p256dh_key)
.set_auth_key(cmd.auth_key)
.set_platform(cmd.platform)
.set_user_agent(cmd.user_agent)
.set_created_at(now)
.set_updated_at(Some(now))
.into();
active.insert(&writer).await?
};
Ok(PushSubscriptionView::from(saved))
}
.await;
metrics::record_outcome(metrics::FEATURE_PUSH_SUBS_UPSERT, &result);
if result.is_ok() {
record_security_operation(
SecurityResource::PushSubscription,
SecurityResourceOp::Upsert,
metrics::OUTCOME_SUCCESS,
);
}
result
}
pub async fn delete_push(svc: &UserService, caller: Uuid, push_id: Uuid) -> UserResult<()> {
let _timer = metrics::Timer::start(metrics::FEATURE_PUSH_SUBS_DELETE);
let result: UserResult<()> = async {
let writer = svc.db.writer();
let row = db::user::user_push_subscriptions::Entity::find_by_id(push_id)
.one(&writer)
.await?
.ok_or_else(|| UserError::NotFound(format!("push subscription {push_id}")))?;
if row.user_id != caller {
return Err(UserError::Forbidden(
"cannot delete other user's push subscription".into(),
));
}
db::user::user_push_subscriptions::Entity::delete_by_id(push_id)
.exec(&writer)
.await?;
Ok(())
}
.await;
metrics::record_outcome(metrics::FEATURE_PUSH_SUBS_DELETE, &result);
match &result {
Ok(_) => record_security_operation(
SecurityResource::PushSubscription,
SecurityResourceOp::Delete,
metrics::OUTCOME_SUCCESS,
),
Err(UserError::Forbidden(_)) => record_security_operation(
SecurityResource::PushSubscription,
SecurityResourceOp::Delete,
"forbidden",
),
Err(UserError::NotFound(_)) => record_security_operation(
SecurityResource::PushSubscription,
SecurityResourceOp::Delete,
"not_found",
),
Err(_) => {}
}
result
}
#[allow(dead_code)]
fn _use_unused(_: &AccessKeyScope, _: &SshKeyType, _: &PushPlatform) {}