feat(api_key): implement API key management with creation, retrieval, and deletion endpoints

This commit is contained in:
2025-09-06 16:18:32 +02:00
parent b83b7acf1c
commit 508f218fc0
22 changed files with 520 additions and 11 deletions

View File

@@ -358,6 +358,7 @@ version = "0.1.0"
dependencies = [
"bcrypt",
"models",
"rand 0.8.5",
"sea-orm",
"validator",
]

View File

@@ -8,7 +8,7 @@ use once_cell::sync::Lazy;
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use app::state::AppState;
use app::{persistence::api_key, state::AppState};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
@@ -28,14 +28,24 @@ impl FromRequestParts<AppState> for AuthUser {
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
state: &AppState,
) -> Result<Self, Self::Rejection> {
// --- Test User ID (Keep for testing) ---
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 = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
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)
.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 "))
.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())
}

View 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))
}

View File

@@ -1,5 +1,6 @@
use axum::Router;
pub mod api_key;
pub mod auth;
pub mod feed;
pub mod root;

View File

@@ -19,17 +19,11 @@ async fn get_thoughts_by_tag(
Path(tag_name): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
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?;
println!("Thoughts with authors: {:?}", thoughts_with_authors);
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
println!("Thoughts schema: {:?}", thoughts_schema);
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}

View File

@@ -19,9 +19,12 @@ use models::schemas::user::{UserListSchema, UserSchema};
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::extractor::{Json, Valid};
use crate::models::ApiErrorResponse;
use crate::{error::ApiError, extractor::AuthUser};
use crate::{
extractor::{Json, Valid},
routers::api_key::create_api_key_router,
};
#[utoipa::path(
get,
@@ -358,6 +361,7 @@ pub fn create_user_router() -> Router<AppState> {
Router::new()
.route("/", get(users_get))
.route("/me", get(get_me).put(update_me))
.nest("/me/api-keys", create_api_key_router())
.route("/{param}", get(get_user_by_param))
.route("/{username}/thoughts", get(user_thoughts_get))
.route(

View File

@@ -12,4 +12,5 @@ path = "src/lib.rs"
bcrypt = "0.17.1"
models = { path = "../models" }
validator = "0.20"
rand = "0.8.5"
sea-orm = { version = "1.1.12" }

View 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(())
}
}

View File

@@ -1,3 +1,4 @@
pub mod api_key;
pub mod auth;
pub mod follow;
pub mod tag;

View File

@@ -19,7 +19,7 @@ use models::schemas::{
user_inbox_post,
user_outbox_get,
get_me,
update_me
update_me,
),
components(schemas(
CreateUserParams,

View File

@@ -4,6 +4,7 @@ mod m20240101_000001_init;
mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields;
mod m20250906_130237_add_tags;
mod m20250906_134056_add_api_keys;
pub struct Migrator;
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250905_000001_init::Migration),
Box::new(m20250906_100000_add_profile_fields::Migration),
Box::new(m20250906_130237_add_tags::Migration),
Box::new(m20250906_134056_add_api_keys::Migration),
]
}
}

View File

@@ -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,
}

View 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 {}

View File

@@ -2,6 +2,7 @@
pub mod prelude;
pub mod api_key;
pub mod follow;
pub mod tag;
pub mod thought;

View File

@@ -1,5 +1,6 @@
//! `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::tag::Entity as Tag;
pub use super::thought::Entity as Thought;

View File

@@ -28,6 +28,9 @@ pub enum Relation {
#[sea_orm(has_many = "super::top_friends::Entity")]
TopFriends,
#[sea_orm(has_many = "super::api_key::Entity")]
ApiKey,
}
impl ActiveModelBehavior for ActiveModel {}

View 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,
}

View File

@@ -1,2 +1,3 @@
pub mod api_key;
pub mod thought;
pub mod user;

View 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);
}

View File

@@ -1,4 +1,5 @@
mod activitypub;
mod api_key;
mod auth;
mod feed;
mod follow;

View File

@@ -25,7 +25,6 @@ async fn test_hashtag_flow() {
// 3. Fetch thoughts by tag "rustlang"
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
println!("Response: {:?}", response);
assert_eq!(response.status(), StatusCode::OK);
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body_bytes).unwrap();

View File

@@ -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].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; }");
}