gitdataai/lib/service/user/access_token.rs
2026-05-30 01:38:40 +08:00

231 lines
7.4 KiB
Rust

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<String>,
#[schema(value_type = Option<String>)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub is_revoked: bool,
#[schema(value_type = String)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[schema(value_type = String)]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[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<String>,
#[schema(value_type = Option<String>)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserAccessToken {
pub name: Option<String>,
pub scopes: Option<Vec<String>>,
#[schema(value_type = Option<String>)]
pub expires_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
}
impl AppService {
pub async fn user_access_tokens(
&self,
ctx: &Session,
) -> Result<Vec<UserAccessToken>, 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<CreatedUserAccessToken, AppError> {
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<UserAccessToken, AppError> {
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<UserAccessToken, AppError> {
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<String>) -> Vec<String> {
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<UserTokenModel> 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,
}
}
}