use argon2::{Argon2, password_hash::PasswordHasher}; use db::sqlx; use model::users::UserTokenModel; use rand::{RngExt, distr::Alphanumeric}; use serde::{Deserialize, Serialize}; use session::Session; use crate::{AppService, error::AppError, session_user}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserAccessToken { pub id: i64, pub name: String, pub scopes: Vec, #[schema(value_type = Option)] pub expires_at: Option>, pub is_revoked: bool, #[schema(value_type = String)] pub created_at: chrono::DateTime, #[schema(value_type = String)] pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CreatedUserAccessToken { pub token: String, pub access_token: UserAccessToken, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateUserAccessToken { pub name: String, pub scopes: Vec, #[schema(value_type = Option)] pub expires_at: Option>, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateUserAccessToken { pub name: Option, pub scopes: Option>, #[schema(value_type = Option)] pub expires_at: Option>>, } impl AppService { pub async fn user_access_tokens( &self, ctx: &Session, ) -> Result, AppError> { let user_uid = session_user(ctx)?; let rows = sqlx::query_as::<_, UserTokenModel>( "SELECT id, \"user\", name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at \ FROM user_token WHERE \"user\" = $1 ORDER BY created_at DESC", ) .bind(user_uid) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn user_create_access_token( &self, ctx: &Session, params: CreateUserAccessToken, ) -> Result { let user_uid = session_user(ctx)?; let name = params.name.trim(); if name.is_empty() { return Err(AppError::BadRequest( "token name is required".to_string(), )); } let token = generate_access_token(); let token_hash = Argon2::default() .hash_password(token.as_bytes()) .map_err(|e| AppError::PasswordHashError(e.to_string()))? .to_string(); let scopes = normalize_scopes(params.scopes); let scopes_str = scopes.join("."); let now = chrono::Utc::now(); let row = sqlx::query_as::<_, UserTokenModel>( "INSERT INTO user_token (\"user\", name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, false, $6, $6) \ RETURNING id, \"user\", name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at", ) .bind(user_uid) .bind(name) .bind(&token_hash) .bind(&scopes_str) .bind(params.expires_at) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(CreatedUserAccessToken { token, access_token: row.into(), }) } pub async fn user_revoke_access_token( &self, ctx: &Session, token_id: i64, ) -> Result<(), AppError> { let user_uid = session_user(ctx)?; let affected = sqlx::query( "UPDATE user_token SET is_revoked = true, updated_at = $1 \ WHERE id = $2 AND \"user\" = $3 AND is_revoked = false", ) .bind(chrono::Utc::now()) .bind(token_id) .bind(user_uid) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .rows_affected(); if affected == 0 { return Err(AppError::NotFound( "access token not found".to_string(), )); } Ok(()) } pub async fn user_update_access_token( &self, ctx: &Session, token_id: i64, params: UpdateUserAccessToken, ) -> Result { let user_uid = session_user(ctx)?; let mut token = self.user_access_token_by_id(user_uid, token_id).await?; if let Some(name) = params.name { let name = name.trim(); if name.is_empty() { return Err(AppError::BadRequest( "token name is required".to_string(), )); } token.name = name.to_string(); } if let Some(scopes) = params.scopes { token.scopes = normalize_scopes(scopes); } if let Some(expires_at) = params.expires_at { token.expires_at = expires_at; } let row = sqlx::query_as::<_, UserTokenModel>( "UPDATE user_token SET name = $1, scopes = $2, expires_at = $3, updated_at = $4 \ WHERE id = $5 AND \"user\" = $6 AND is_revoked = false \ RETURNING id, \"user\", name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at", ) .bind(&token.name) .bind(token.scopes.join(".")) .bind(token.expires_at) .bind(chrono::Utc::now()) .bind(token_id) .bind(user_uid) .fetch_optional(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("access token not found".to_string()))?; Ok(row.into()) } async fn user_access_token_by_id( &self, user_uid: uuid::Uuid, token_id: i64, ) -> Result { sqlx::query_as::<_, UserTokenModel>( "SELECT id, \"user\", name, token_hash, scopes, expires_at, is_revoked, created_at, updated_at \ FROM user_token WHERE id = $1 AND \"user\" = $2 AND is_revoked = false", ) .bind(token_id) .bind(user_uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .map(Into::into) .ok_or_else(|| AppError::NotFound("access token not found".to_string())) } } fn normalize_scopes(scopes: Vec) -> Vec { scopes .into_iter() .map(|scope| scope.trim().to_string()) .filter(|scope| !scope.is_empty() && !scope.contains('.')) .collect() } fn generate_access_token() -> String { #[allow(deprecated)] let mut rng = rand::rng(); let token: String = (0..64).map(|_| rng.sample(Alphanumeric) as char).collect(); format!("gda_{}", token) } impl From for UserAccessToken { fn from(value: UserTokenModel) -> Self { Self { id: value.id, name: value.name, scopes: value .scopes .split('.') .filter(|scope| !scope.is_empty()) .map(ToString::to_string) .collect(), expires_at: value.expires_at, is_revoked: value.is_revoked, created_at: value.created_at, updated_at: value.updated_at, } } }