Skip to content
Clone
eba4fd1
293 lines 9.8 KB Rust
use models::users::user_access_keys::AccessKeyScope;
use models::users::{user, user_access_keys};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use super::AuthService;
use crate::errors::AuthError;
use crate::share::crypto::{constant_time_eq, random_token, sha256_hex};
use crate::share::types::ListQuery;
const ACCESS_TOKEN_BYTES: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CreateAccessKeyCmd {
pub name: String,
pub scope: AccessKeyScope,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
)]
pub expires_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AccessKeyDto {
pub id: Uuid,
pub name: String,
pub scope: AccessKeyScope,
pub created_at: OffsetDateTime,
pub expires_at: Option<OffsetDateTime>,
pub last_used_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CreateAccessKeyResp {
pub key: AccessKeyDto,
pub token: String,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateAccessKeyCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub scope: Option<AccessKeyScope>,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
)]
pub expires_at: Option<Option<OffsetDateTime>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiedAccessKey {
pub user_id: Uuid,
pub scope: AccessKeyScope,
}
impl AuthService {
pub async fn create_access_key(
&self,
uid: Uuid,
cmd: CreateAccessKeyCmd,
) -> Result<CreateAccessKeyResp, AuthError> {
let name = cmd.name.trim().to_string();
if name.is_empty() || name.len() > 64 {
return Err(AuthError::BadRequest(
"name must be between 1 and 64 characters".to_string(),
));
}
let db = self.db();
let key_id = Uuid::now_v7();
let secret = random_token(ACCESS_TOKEN_BYTES);
let token = format!("{key_id}.{secret}");
let salt = random_token(8);
let now = OffsetDateTime::now_utc();
let row = user_access_keys::ActiveModel {
id: Set(key_id),
user_id: Set(uid),
name: Set(name.clone()),
token_hash: Set(sha256_hex(format!("{secret}{salt}").as_bytes())),
token_salt: Set(salt),
token_last_eight: Set(token[token.len().saturating_sub(8)..].to_string()),
scope: Set(cmd.scope),
version: Set(1),
created_at: Set(now),
updated_at: Set(now),
expires_at: Set(cmd.expires_at),
last_used_at: Set(None),
};
let inserted = row.insert(db.write()).await?;
Ok(CreateAccessKeyResp {
key: AccessKeyDto {
id: inserted.id,
name,
scope: inserted.scope,
created_at: inserted.created_at,
expires_at: inserted.expires_at,
last_used_at: inserted.last_used_at,
},
token,
})
}
pub async fn list_access_keys(
&self,
uid: Uuid,
query: ListQuery,
) -> Result<export::pagination::Page<AccessKeyDto>, AuthError> {
let (limit, offset) = query.page();
let total = user_access_keys::Entity::find()
.filter(user_access_keys::Column::UserId.eq(uid))
.count(self.db().read())
.await? as i64;
let rows = user_access_keys::Entity::find()
.filter(user_access_keys::Column::UserId.eq(uid))
.order_by_desc(user_access_keys::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
Ok(export::pagination::Page {
items: rows
.into_iter()
.map(|row| AccessKeyDto {
id: row.id,
name: row.name,
scope: row.scope,
created_at: row.created_at,
expires_at: row.expires_at,
last_used_at: row.last_used_at,
})
.collect(),
total,
limit,
offset,
})
}
pub async fn update_access_key(
&self,
uid: Uuid,
key_id: Uuid,
cmd: UpdateAccessKeyCmd,
) -> Result<AccessKeyDto, AuthError> {
let db = self.db();
let row = user_access_keys::Entity::find_by_id(key_id)
.one(db.read())
.await?
.filter(|k| k.user_id == uid)
.ok_or(AuthError::NotFound)?;
let mut am: user_access_keys::ActiveModel = row.into();
if let Some(name) = cmd.name {
let name = name.trim().to_string();
if name.is_empty() || name.len() > 64 {
return Err(AuthError::BadRequest(
"name must be between 1 and 64 characters".to_string(),
));
}
am.name = Set(name);
}
if let Some(scope) = cmd.scope {
am.scope = Set(scope);
}
if let Some(expires_at) = cmd.expires_at {
am.expires_at = Set(expires_at);
}
am.updated_at = Set(OffsetDateTime::now_utc());
let updated = am.update(db.write()).await?;
Ok(AccessKeyDto {
id: updated.id,
name: updated.name,
scope: updated.scope,
created_at: updated.created_at,
expires_at: updated.expires_at,
last_used_at: updated.last_used_at,
})
}
pub async fn rotate_access_key(
&self,
uid: Uuid,
key_id: Uuid,
) -> Result<CreateAccessKeyResp, AuthError> {
let db = self.db();
let row = user_access_keys::Entity::find_by_id(key_id)
.one(db.read())
.await?
.filter(|k| k.user_id == uid)
.ok_or(AuthError::NotFound)?;
let secret = random_token(ACCESS_TOKEN_BYTES);
let token = format!("{key_id}.{secret}");
let salt = random_token(8);
let now = OffsetDateTime::now_utc();
let mut am: user_access_keys::ActiveModel = row.into();
am.token_hash = Set(sha256_hex(format!("{secret}{salt}").as_bytes()));
am.token_salt = Set(salt);
am.token_last_eight = Set(token[token.len().saturating_sub(8)..].to_string());
am.version = Set(am.version.as_ref() + 1);
am.updated_at = Set(now);
let updated = am.update(db.write()).await?;
Ok(CreateAccessKeyResp {
key: AccessKeyDto {
id: updated.id,
name: updated.name,
scope: updated.scope,
created_at: updated.created_at,
expires_at: updated.expires_at,
last_used_at: updated.last_used_at,
},
token,
})
}
pub async fn delete_access_key(&self, uid: Uuid, key_id: Uuid) -> Result<(), AuthError> {
let db = self.db();
let result = user_access_keys::Entity::delete_many()
.filter(user_access_keys::Column::Id.eq(key_id))
.filter(user_access_keys::Column::UserId.eq(uid))
.exec(db.write())
.await?;
if result.rows_affected == 0 {
return Err(AuthError::NotFound);
}
Ok(())
}
pub async fn verify_access_token(
&self,
token: &str,
) -> Result<Option<VerifiedAccessKey>, AuthError> {
let Some((key_id, _secret)) = token.split_once('.') else {
return Ok(None);
};
let Ok(key_id) = Uuid::parse_str(key_id) else {
return Ok(None);
};
let db = self.db();
let Some(row) = user_access_keys::Entity::find_by_id(key_id)
.one(db.read())
.await?
else {
return Ok(None);
};
let Some(user) = user::Entity::find_by_id(row.user_id).one(db.read()).await? else {
return Ok(None);
};
if !user.is_active || user.prohibit_login || user.is_restricted {
return Ok(None);
}
let secret = token.split_once('.').map(|(_, s)| s).unwrap_or_default();
let computed = sha256_hex(format!("{secret}{}", row.token_salt).as_bytes());
if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
return Ok(None);
}
let now = OffsetDateTime::now_utc();
if let Some(expires_at) = row.expires_at
&& expires_at <= now
{
return Ok(None);
}
let stale = row
.last_used_at
.map(|t| (now - t).whole_minutes() >= 1)
.unwrap_or(true);
if stale {
let _ = user_access_keys::Entity::update_many()
.col_expr(
user_access_keys::Column::LastUsedAt,
sea_orm::sea_query::Expr::value(now),
)
.filter(user_access_keys::Column::Id.eq(row.id))
.filter(
sea_orm::Condition::any()
.add(user_access_keys::Column::LastUsedAt.is_null())
.add(
user_access_keys::Column::LastUsedAt
.lt(now - time::Duration::minutes(1)),
),
)
.exec(self.db().write())
.await;
}
let user_id = row.user_id;
let scope = row.scope;
Ok(Some(VerifiedAccessKey { user_id, scope }))
}
}