feat(api_key): implement API key management with creation, retrieval, and deletion endpoints
This commit is contained in:
1
thoughts-backend/Cargo.lock
generated
1
thoughts-backend/Cargo.lock
generated
@@ -358,6 +358,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"models",
|
"models",
|
||||||
|
"rand 0.8.5",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"validator",
|
"validator",
|
||||||
]
|
]
|
||||||
|
@@ -8,7 +8,7 @@ use once_cell::sync::Lazy;
|
|||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use app::state::AppState;
|
use app::{persistence::api_key, state::AppState};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
@@ -28,14 +28,24 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
|
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
_state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
// --- Test User ID (Keep for testing) ---
|
||||||
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
||||||
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
let user_id_str = user_id_header.to_str().unwrap_or("0");
|
||||||
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
|
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
|
||||||
return Ok(AuthUser { id: user_id });
|
return Ok(AuthUser { id: user_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- API Key Authentication ---
|
||||||
|
if let Some(api_key) = get_api_key_from_header(&parts.headers) {
|
||||||
|
return match api_key::validate_api_key(&state.conn, &api_key).await {
|
||||||
|
Ok(user) => Ok(AuthUser { id: user.id }),
|
||||||
|
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JWT Authentication (Fallback) ---
|
||||||
let token = get_token_from_header(&parts.headers)
|
let token = get_token_from_header(&parts.headers)
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
|
||||||
|
|
||||||
@@ -56,3 +66,11 @@ fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
|
|||||||
.and_then(|header| header.strip_prefix("Bearer "))
|
.and_then(|header| header.strip_prefix("Bearer "))
|
||||||
.map(|token| token.to_owned())
|
.map(|token| token.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_api_key_from_header(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header| header.to_str().ok())
|
||||||
|
.and_then(|header| header.strip_prefix("ApiKey "))
|
||||||
|
.map(|key| key.to_owned())
|
||||||
|
}
|
||||||
|
93
thoughts-backend/api/src/routers/api_key.rs
Normal file
93
thoughts-backend/api/src/routers/api_key.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
extractor::{AuthUser, Json},
|
||||||
|
models::ApiErrorResponse,
|
||||||
|
};
|
||||||
|
use app::{persistence::api_key, state::AppState};
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{delete, get},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse};
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/me/api-keys",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
|
||||||
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearerAuth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_keys(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?;
|
||||||
|
Ok(Json(ApiKeyListSchema::from(keys)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/me/api-keys",
|
||||||
|
request_body = ApiKeyRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "API key created", body = ApiKeyResponse),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearerAuth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn create_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Json(params): Json<ApiKeyRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let (key_model, plaintext_key) =
|
||||||
|
api_key::create_api_key(&state.conn, auth_user.id, params.name).await?;
|
||||||
|
|
||||||
|
let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key));
|
||||||
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/me/api-keys/{key_id}",
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "API key deleted"),
|
||||||
|
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
|
||||||
|
(status = 404, description = "API key not found", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("key_id" = Uuid, Path, description = "The ID of the API key to delete")
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearerAuth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn delete_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(key_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_api_key_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(get_keys).post(create_key))
|
||||||
|
.route("/{key_id}", delete(delete_key))
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
|
@@ -19,17 +19,11 @@ async fn get_thoughts_by_tag(
|
|||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await;
|
let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await;
|
||||||
println!(
|
|
||||||
"Result from get_thoughts_by_tag_name: {:?}",
|
|
||||||
thoughts_with_authors
|
|
||||||
);
|
|
||||||
let thoughts_with_authors = thoughts_with_authors?;
|
let thoughts_with_authors = thoughts_with_authors?;
|
||||||
println!("Thoughts with authors: {:?}", thoughts_with_authors);
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ThoughtSchema::from)
|
.map(ThoughtSchema::from)
|
||||||
.collect();
|
.collect();
|
||||||
println!("Thoughts schema: {:?}", thoughts_schema);
|
|
||||||
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -19,9 +19,12 @@ use models::schemas::user::{UserListSchema, UserSchema};
|
|||||||
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
||||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::extractor::{Json, Valid};
|
|
||||||
use crate::models::ApiErrorResponse;
|
use crate::models::ApiErrorResponse;
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
use crate::{
|
||||||
|
extractor::{Json, Valid},
|
||||||
|
routers::api_key::create_api_key_router,
|
||||||
|
};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -358,6 +361,7 @@ pub fn create_user_router() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(users_get))
|
.route("/", get(users_get))
|
||||||
.route("/me", get(get_me).put(update_me))
|
.route("/me", get(get_me).put(update_me))
|
||||||
|
.nest("/me/api-keys", create_api_key_router())
|
||||||
.route("/{param}", get(get_user_by_param))
|
.route("/{param}", get(get_user_by_param))
|
||||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||||
.route(
|
.route(
|
||||||
|
@@ -12,4 +12,5 @@ path = "src/lib.rs"
|
|||||||
bcrypt = "0.17.1"
|
bcrypt = "0.17.1"
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
validator = "0.20"
|
validator = "0.20"
|
||||||
|
rand = "0.8.5"
|
||||||
sea-orm = { version = "1.1.12" }
|
sea-orm = { version = "1.1.12" }
|
||||||
|
93
thoughts-backend/app/src/persistence/api_key.rs
Normal file
93
thoughts-backend/app/src/persistence/api_key.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
|
use models::domains::{api_key, user};
|
||||||
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use sea_orm::{
|
||||||
|
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
|
||||||
|
const KEY_PREFIX: &str = "th_";
|
||||||
|
const KEY_RANDOM_LENGTH: usize = 32;
|
||||||
|
const KEY_LOOKUP_PREFIX_LENGTH: usize = 8;
|
||||||
|
|
||||||
|
fn generate_key() -> String {
|
||||||
|
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH);
|
||||||
|
format!("{}{}", KEY_PREFIX, random_part)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_key(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(api_key::Model, String), UserError> {
|
||||||
|
let plaintext_key = generate_key();
|
||||||
|
let key_hash =
|
||||||
|
hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string();
|
||||||
|
|
||||||
|
let new_key = api_key::ActiveModel {
|
||||||
|
user_id: Set(user_id),
|
||||||
|
name: Set(name),
|
||||||
|
key_hash: Set(key_hash),
|
||||||
|
key_prefix: Set(key_prefix),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((new_key, plaintext_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result<user::Model, UserError> {
|
||||||
|
if !plaintext_key.starts_with(KEY_PREFIX)
|
||||||
|
|| plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH
|
||||||
|
{
|
||||||
|
return Err(UserError::Validation("Invalid API key format".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH];
|
||||||
|
|
||||||
|
let candidate_keys = api_key::Entity::find()
|
||||||
|
.filter(api_key::Column::KeyPrefix.eq(key_prefix))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
for key in candidate_keys {
|
||||||
|
if verify(plaintext_key, &key.key_hash).unwrap_or(false) {
|
||||||
|
return super::user::get_user(db, key.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(UserError::Validation("Invalid API key".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_api_keys_for_user(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Vec<api_key::Model>, DbErr> {
|
||||||
|
api_key::Entity::find()
|
||||||
|
.filter(api_key::Column::UserId.eq(user_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> {
|
||||||
|
let result = api_key::Entity::delete_many()
|
||||||
|
.filter(api_key::Column::Id.eq(key_id))
|
||||||
|
.filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key
|
||||||
|
.exec(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected == 0 {
|
||||||
|
Err(UserError::NotFound)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod api_key;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
@@ -19,7 +19,7 @@ use models::schemas::{
|
|||||||
user_inbox_post,
|
user_inbox_post,
|
||||||
user_outbox_get,
|
user_outbox_get,
|
||||||
get_me,
|
get_me,
|
||||||
update_me
|
update_me,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateUserParams,
|
CreateUserParams,
|
||||||
|
@@ -4,6 +4,7 @@ mod m20240101_000001_init;
|
|||||||
mod m20250905_000001_init;
|
mod m20250905_000001_init;
|
||||||
mod m20250906_100000_add_profile_fields;
|
mod m20250906_100000_add_profile_fields;
|
||||||
mod m20250906_130237_add_tags;
|
mod m20250906_130237_add_tags;
|
||||||
|
mod m20250906_134056_add_api_keys;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20250905_000001_init::Migration),
|
Box::new(m20250905_000001_init::Migration),
|
||||||
Box::new(m20250906_100000_add_profile_fields::Migration),
|
Box::new(m20250906_100000_add_profile_fields::Migration),
|
||||||
Box::new(m20250906_130237_add_tags::Migration),
|
Box::new(m20250906_130237_add_tags::Migration),
|
||||||
|
Box::new(m20250906_134056_add_api_keys::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,69 @@
|
|||||||
|
use super::m20240101_000001_init::User;
|
||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(ApiKey::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(ApiKey::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key()
|
||||||
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
|
)
|
||||||
|
.col(uuid(ApiKey::UserId).not_null())
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_api_key_user_id")
|
||||||
|
.from(ApiKey::Table, ApiKey::UserId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.col(text(ApiKey::KeyHash).not_null().unique_key())
|
||||||
|
.col(string(ApiKey::Name).not_null())
|
||||||
|
.col(
|
||||||
|
timestamp_with_time_zone(ApiKey::CreatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx-api_keys-key_prefix")
|
||||||
|
.table(ApiKey::Table)
|
||||||
|
.col(ApiKey::KeyPrefix)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(ApiKey::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum ApiKey {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
UserId,
|
||||||
|
KeyHash,
|
||||||
|
Name,
|
||||||
|
CreatedAt,
|
||||||
|
KeyPrefix,
|
||||||
|
}
|
32
thoughts-backend/models/src/domains/api_key.rs
Normal file
32
thoughts-backend/models/src/domains/api_key.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "api_key")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub key_prefix: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub key_hash: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod api_key;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||||
|
|
||||||
|
pub use super::api_key::Entity as ApiKey;
|
||||||
pub use super::follow::Entity as Follow;
|
pub use super::follow::Entity as Follow;
|
||||||
pub use super::tag::Entity as Tag;
|
pub use super::tag::Entity as Tag;
|
||||||
pub use super::thought::Entity as Thought;
|
pub use super::thought::Entity as Thought;
|
||||||
|
@@ -28,6 +28,9 @@ pub enum Relation {
|
|||||||
|
|
||||||
#[sea_orm(has_many = "super::top_friends::Entity")]
|
#[sea_orm(has_many = "super::top_friends::Entity")]
|
||||||
TopFriends,
|
TopFriends,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::api_key::Entity")]
|
||||||
|
ApiKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
62
thoughts-backend/models/src/schemas/api_key.rs
Normal file
62
thoughts-backend/models/src/schemas/api_key.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use crate::domains::api_key;
|
||||||
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ApiKeySchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub key_prefix: String,
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ApiKeyResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: ApiKeySchema,
|
||||||
|
/// The full plaintext API key. This is only returned on creation.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub plaintext_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiKeyResponse {
|
||||||
|
pub fn from_parts(model: api_key::Model, plaintext_key: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
key: ApiKeySchema {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
key_prefix: model.key_prefix,
|
||||||
|
created_at: model.created_at.into(),
|
||||||
|
},
|
||||||
|
plaintext_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ApiKeyListSchema {
|
||||||
|
pub api_keys: Vec<ApiKeySchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<api_key::Model>> for ApiKeyListSchema {
|
||||||
|
fn from(keys: Vec<api_key::Model>) -> Self {
|
||||||
|
Self {
|
||||||
|
api_keys: keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|k| ApiKeySchema {
|
||||||
|
id: k.id,
|
||||||
|
name: k.name,
|
||||||
|
key_prefix: k.key_prefix,
|
||||||
|
created_at: k.created_at.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
@@ -1,2 +1,3 @@
|
|||||||
|
pub mod api_key;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
79
thoughts-backend/tests/api/api_key.rs
Normal file
79
thoughts-backend/tests/api/api_key.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
use axum::http::{header, HeaderName, StatusCode};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use utils::testing::{make_jwt_request, make_request_with_headers};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_api_key_flow() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _ = create_user_with_password(&app.db, "apikey_user", "password123").await;
|
||||||
|
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
|
||||||
|
|
||||||
|
// 1. Create a new API key using JWT auth
|
||||||
|
let create_body = json!({ "name": "My Test Key" }).to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me/api-keys",
|
||||||
|
"POST",
|
||||||
|
Some(create_body),
|
||||||
|
&jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
let plaintext_key = v["plaintext_key"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Plaintext key not found")
|
||||||
|
.to_string();
|
||||||
|
let key_id = v["id"].as_str().expect("Key ID not found").to_string();
|
||||||
|
assert!(plaintext_key.starts_with("th_"));
|
||||||
|
|
||||||
|
// 2. Use the new API key to post a thought
|
||||||
|
|
||||||
|
let thought_body = json!({ "content": "Posting with an API key!" }).to_string();
|
||||||
|
let key = plaintext_key.clone();
|
||||||
|
let api_key_header = format!("ApiKey {}", key);
|
||||||
|
let content_type = "application/json";
|
||||||
|
let headers: Vec<(HeaderName, &str)> = vec![
|
||||||
|
(header::AUTHORIZATION, &api_key_header),
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = make_request_with_headers(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(thought_body),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
// 3. Delete the API key using JWT auth
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/users/me/api-keys/{}", key_id),
|
||||||
|
"DELETE",
|
||||||
|
None,
|
||||||
|
&jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// 4. Try to use the deleted key again, expecting failure
|
||||||
|
let body = json!({ "content": "This should fail" }).to_string();
|
||||||
|
let headers: Vec<(HeaderName, &str)> = vec![
|
||||||
|
(header::AUTHORIZATION, &api_key_header),
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response =
|
||||||
|
make_request_with_headers(app.router.clone(), "/thoughts", "POST", Some(body), headers)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
mod activitypub;
|
mod activitypub;
|
||||||
|
mod api_key;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod follow;
|
mod follow;
|
||||||
|
@@ -25,7 +25,6 @@ async fn test_hashtag_flow() {
|
|||||||
|
|
||||||
// 3. Fetch thoughts by tag "rustlang"
|
// 3. Fetch thoughts by tag "rustlang"
|
||||||
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
|
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
|
||||||
println!("Response: {:?}", response);
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
|
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: Value = serde_json::from_slice(&body_bytes).unwrap();
|
let v: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
@@ -183,3 +183,56 @@ async fn test_update_me_top_friends() {
|
|||||||
assert_eq!(top_friends_list_2[0].friend_id, friend2.id);
|
assert_eq!(top_friends_list_2[0].friend_id, friend2.id);
|
||||||
assert_eq!(top_friends_list_2[0].position, 1);
|
assert_eq!(top_friends_list_2[0].position, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_me_css_and_images() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Create and log in as a user
|
||||||
|
let _ = create_user_with_password(&app.db, "css_user", "password123").await;
|
||||||
|
let token = login_user(app.router.clone(), "css_user", "password123").await;
|
||||||
|
|
||||||
|
// 2. Attempt to update with an invalid avatar URL
|
||||||
|
let invalid_body = json!({
|
||||||
|
"avatar_url": "not-a-valid-url"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(invalid_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
// 3. Update profile with valid URLs and custom CSS
|
||||||
|
let valid_body = json!({
|
||||||
|
"avatar_url": "https://example.com/new-avatar.png",
|
||||||
|
"header_url": "https://example.com/new-header.jpg",
|
||||||
|
"custom_css": "body { color: blue; }"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(valid_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 4. Verify the changes were persisted by fetching the profile again
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["avatar_url"], "https://example.com/new-avatar.png");
|
||||||
|
assert_eq!(v["header_url"], "https://example.com/new-header.jpg");
|
||||||
|
assert_eq!(v["custom_css"], "body { color: blue; }");
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user