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, } #[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, pub last_used_at: Option, } #[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, #[serde(default)] pub scope: Option, #[serde( default, deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime" )] pub expires_at: Option>, } #[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 { 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, 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 { 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 { 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, 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 })) } }