231 lines
7.4 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|