mirror of
https://github.com/CloudNebulaProject/webfingerd.git
synced 2026-04-10 13:10:41 +00:00
feat: add domain onboarding API with ACME-style challenges
This commit is contained in:
parent
7aa5a6738c
commit
9829f84034
3 changed files with 433 additions and 0 deletions
274
src/handler/domains.rs
Normal file
274
src/handler/domains.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use sea_orm::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth;
|
||||
use crate::entity::domains;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateDomainRequest {
|
||||
domain: String,
|
||||
challenge_type: String,
|
||||
}
|
||||
|
||||
async fn create_domain(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateDomainRequest>,
|
||||
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||||
if req.challenge_type != "dns-01" && req.challenge_type != "http-01" {
|
||||
return Err(AppError::BadRequest(
|
||||
"challenge_type must be dns-01 or http-01".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
let existing = domains::Entity::find()
|
||||
.filter(domains::Column::Domain.eq(&req.domain))
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("domain already registered".into()));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let challenge_token = auth::generate_secret();
|
||||
let registration_secret = auth::generate_secret();
|
||||
let registration_secret_hash = auth::hash_token(®istration_secret)
|
||||
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||||
|
||||
let domain = domains::ActiveModel {
|
||||
id: Set(id.clone()),
|
||||
domain: Set(req.domain.clone()),
|
||||
owner_token_hash: Set(String::new()), // Set on verification
|
||||
registration_secret: Set(registration_secret_hash),
|
||||
challenge_type: Set(req.challenge_type.clone()),
|
||||
challenge_token: Set(Some(challenge_token.clone())),
|
||||
verified: Set(false),
|
||||
created_at: Set(chrono::Utc::now().naive_utc()),
|
||||
verified_at: Set(None),
|
||||
};
|
||||
|
||||
domain.insert(&state.db).await?;
|
||||
|
||||
let instructions = match req.challenge_type.as_str() {
|
||||
"dns-01" => format!(
|
||||
"Create a TXT record at {}.{} with value: {}",
|
||||
state.settings.challenge.dns_txt_prefix, req.domain, challenge_token
|
||||
),
|
||||
"http-01" => format!(
|
||||
"Serve the challenge at https://{}/{}/{}",
|
||||
req.domain, state.settings.challenge.http_well_known_path, challenge_token
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"id": id,
|
||||
"challenge_token": challenge_token,
|
||||
"challenge_type": req.challenge_type,
|
||||
"registration_secret": registration_secret,
|
||||
"instructions": instructions,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VerifyRequest {
|
||||
registration_secret: String,
|
||||
}
|
||||
|
||||
async fn verify_domain(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<VerifyRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let domain = domains::Entity::find_by_id(&id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Verify registration secret
|
||||
if !auth::verify_token(&req.registration_secret, &domain.registration_secret) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
if domain.verified {
|
||||
return Err(AppError::Conflict("domain already verified".into()));
|
||||
}
|
||||
|
||||
let challenge_token = domain
|
||||
.challenge_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::BadRequest("no pending challenge".into()))?;
|
||||
|
||||
// Check challenge TTL
|
||||
let challenge_age = chrono::Utc::now().naive_utc() - domain.created_at;
|
||||
if challenge_age.num_seconds() > state.settings.challenge.challenge_ttl_secs as i64 {
|
||||
return Err(AppError::BadRequest("challenge expired".into()));
|
||||
}
|
||||
|
||||
// Verify the challenge via the injected verifier (mockable in tests)
|
||||
let verified = match domain.challenge_type.as_str() {
|
||||
"dns-01" => state.challenge_verifier
|
||||
.verify_dns(&domain.domain, challenge_token, &state.settings.challenge)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e))?,
|
||||
"http-01" => state.challenge_verifier
|
||||
.verify_http(&domain.domain, challenge_token, &state.settings.challenge)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e))?,
|
||||
_ => return Err(AppError::Internal("unknown challenge type".into())),
|
||||
};
|
||||
|
||||
if !verified {
|
||||
return Err(AppError::BadRequest("challenge verification failed".into()));
|
||||
}
|
||||
|
||||
// Generate owner token (prefixed with domain ID for O(1) lookup)
|
||||
let owner_token = auth::generate_token(&id);
|
||||
let owner_token_hash = auth::hash_token(&owner_token)
|
||||
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||||
|
||||
// Update domain
|
||||
let mut active: domains::ActiveModel = domain.into();
|
||||
active.verified = Set(true);
|
||||
active.verified_at = Set(Some(chrono::Utc::now().naive_utc()));
|
||||
active.owner_token_hash = Set(owner_token_hash);
|
||||
active.challenge_token = Set(None);
|
||||
active.registration_secret = Set(String::new()); // Invalidate
|
||||
active.update(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"verified": true,
|
||||
"owner_token": owner_token,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Extract and verify owner token from Authorization header.
|
||||
/// The token format is `{domain_id}.{secret}` -- the domain_id from the token
|
||||
/// must match the `id` path parameter to prevent cross-domain access.
|
||||
pub async fn authenticate_owner(
|
||||
db: &DatabaseConnection,
|
||||
id: &str,
|
||||
auth_header: Option<&str>,
|
||||
) -> AppResult<domains::Model> {
|
||||
let full_token = auth_header
|
||||
.and_then(|h| h.strip_prefix("Bearer "))
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
// Verify the token's embedded ID matches the requested domain
|
||||
let (token_domain_id, _) = auth::split_token(full_token)
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
if token_domain_id != id {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let domain = domains::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if !domain.verified {
|
||||
return Err(AppError::Forbidden("domain not verified".into()));
|
||||
}
|
||||
|
||||
if !auth::verify_token(full_token, &domain.owner_token_hash) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(domain)
|
||||
}
|
||||
|
||||
async fn get_domain(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"id": domain.id,
|
||||
"domain": domain.domain,
|
||||
"verified": domain.verified,
|
||||
"challenge_type": domain.challenge_type,
|
||||
"created_at": domain.created_at.to_string(),
|
||||
"verified_at": domain.verified_at.map(|v| v.to_string()),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn rotate_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
|
||||
|
||||
let new_token = auth::generate_token(&domain.id);
|
||||
let new_hash = auth::hash_token(&new_token)
|
||||
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||||
|
||||
let mut active: domains::ActiveModel = domain.into();
|
||||
active.owner_token_hash = Set(new_hash);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"owner_token": new_token,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn delete_domain(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> AppResult<StatusCode> {
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
|
||||
|
||||
// Query all resource URIs for this domain before deleting
|
||||
use crate::entity::resources;
|
||||
let resource_uris: Vec<String> = resources::Entity::find()
|
||||
.filter(resources::Column::DomainId.eq(&domain.id))
|
||||
.all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.resource_uri)
|
||||
.collect();
|
||||
|
||||
// Cascade: delete domain (FK cascades handle DB rows)
|
||||
domains::Entity::delete_by_id(&domain.id)
|
||||
.exec(&state.db)
|
||||
.await?;
|
||||
|
||||
// Evict cache entries for all affected resources
|
||||
state.cache.remove_many(&resource_uris);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/domains", post(create_domain))
|
||||
.route("/api/v1/domains/{id}", get(get_domain).delete(delete_domain))
|
||||
.route("/api/v1/domains/{id}/verify", post(verify_domain))
|
||||
.route("/api/v1/domains/{id}/rotate-token", post(rotate_token))
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod domains;
|
||||
mod health;
|
||||
mod host_meta;
|
||||
mod webfinger;
|
||||
|
|
@ -9,6 +10,7 @@ pub fn router(state: AppState) -> Router {
|
|||
Router::new()
|
||||
.merge(webfinger::router())
|
||||
.merge(host_meta::router())
|
||||
.merge(domains::router())
|
||||
.merge(health::router())
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
|
|||
157
tests/test_domains.rs
Normal file
157
tests/test_domains.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
mod common;
|
||||
|
||||
use axum_test::TestServer;
|
||||
use serde_json::json;
|
||||
use webfingerd::handler;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_domain() {
|
||||
let state = common::test_state().await;
|
||||
let app = handler::router(state);
|
||||
let server = TestServer::new(app);
|
||||
|
||||
let response = server
|
||||
.post("/api/v1/domains")
|
||||
.json(&json!({
|
||||
"domain": "example.com",
|
||||
"challenge_type": "dns-01"
|
||||
}))
|
||||
.await;
|
||||
|
||||
response.assert_status(axum::http::StatusCode::CREATED);
|
||||
let body: serde_json::Value = response.json();
|
||||
assert!(body["id"].is_string());
|
||||
assert!(body["challenge_token"].is_string());
|
||||
assert!(body["registration_secret"].is_string());
|
||||
assert_eq!(body["challenge_type"], "dns-01");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_duplicate_domain_returns_409() {
|
||||
let state = common::test_state().await;
|
||||
let app = handler::router(state);
|
||||
let server = TestServer::new(app);
|
||||
|
||||
server
|
||||
.post("/api/v1/domains")
|
||||
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||||
.await;
|
||||
|
||||
let response = server
|
||||
.post("/api/v1/domains")
|
||||
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||||
.await;
|
||||
|
||||
response.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_domain_requires_auth() {
|
||||
let state = common::test_state().await;
|
||||
let app = handler::router(state);
|
||||
let server = TestServer::new(app);
|
||||
|
||||
let create_resp = server
|
||||
.post("/api/v1/domains")
|
||||
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||||
.await;
|
||||
let id = create_resp.json::<serde_json::Value>()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// No auth header
|
||||
let response = server.get(&format!("/api/v1/domains/{id}")).await;
|
||||
response.assert_status_unauthorized();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_domain_with_valid_owner_token() {
|
||||
let state = common::test_state().await;
|
||||
let app = handler::router(state.clone());
|
||||
let server = TestServer::new(app);
|
||||
|
||||
// Register domain
|
||||
let create_resp = server
|
||||
.post("/api/v1/domains")
|
||||
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||||
.await;
|
||||
|
||||
let body: serde_json::Value = create_resp.json();
|
||||
let id = body["id"].as_str().unwrap();
|
||||
let reg_secret = body["registration_secret"].as_str().unwrap();
|
||||
|
||||
// Verify (MockChallengeVerifier always succeeds)
|
||||
let verify_resp = server
|
||||
.post(&format!("/api/v1/domains/{id}/verify"))
|
||||
.json(&json!({"registration_secret": reg_secret}))
|
||||
.await;
|
||||
|
||||
verify_resp.assert_status_ok();
|
||||
let owner_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// Use owner token to get domain
|
||||
let response = server
|
||||
.get(&format!("/api/v1/domains/{id}"))
|
||||
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||||
.await;
|
||||
|
||||
response.assert_status_ok();
|
||||
let body: serde_json::Value = response.json();
|
||||
assert_eq!(body["domain"], "example.com");
|
||||
assert_eq!(body["verified"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_token() {
|
||||
let state = common::test_state().await;
|
||||
let app = handler::router(state.clone());
|
||||
let server = TestServer::new(app);
|
||||
|
||||
// Register domain
|
||||
let create_resp = server
|
||||
.post("/api/v1/domains")
|
||||
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||||
.await;
|
||||
let body: serde_json::Value = create_resp.json();
|
||||
let id = body["id"].as_str().unwrap();
|
||||
let reg_secret = body["registration_secret"].as_str().unwrap();
|
||||
|
||||
// Verify (MockChallengeVerifier always succeeds)
|
||||
let verify_resp = server
|
||||
.post(&format!("/api/v1/domains/{id}/verify"))
|
||||
.json(&json!({"registration_secret": reg_secret}))
|
||||
.await;
|
||||
let old_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// Rotate
|
||||
let rotate_resp = server
|
||||
.post(&format!("/api/v1/domains/{id}/rotate-token"))
|
||||
.add_header("Authorization", format!("Bearer {old_token}"))
|
||||
.await;
|
||||
rotate_resp.assert_status_ok();
|
||||
let new_token = rotate_resp.json::<serde_json::Value>()["owner_token"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// Old token should fail
|
||||
let response = server
|
||||
.get(&format!("/api/v1/domains/{id}"))
|
||||
.add_header("Authorization", format!("Bearer {old_token}"))
|
||||
.await;
|
||||
response.assert_status_unauthorized();
|
||||
|
||||
// New token should work
|
||||
let response = server
|
||||
.get(&format!("/api/v1/domains/{id}"))
|
||||
.add_header("Authorization", format!("Bearer {new_token}"))
|
||||
.await;
|
||||
response.assert_status_ok();
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue