mirror of
https://github.com/CloudNebulaProject/webfingerd.git
synced 2026-04-10 13:10:41 +00:00
feat: add SeaORM entities, cache, auth helpers, and AppState
This commit is contained in:
parent
c993f4d703
commit
1d4873ba75
10 changed files with 451 additions and 0 deletions
44
src/auth.rs
Normal file
44
src/auth.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
/// Generate a prefixed token: `{id}.{random_secret}`.
|
||||||
|
/// The id allows O(1) lookup; the secret is verified via argon2.
|
||||||
|
/// The `id` parameter is the entity UUID this token belongs to.
|
||||||
|
pub fn generate_token(id: &str) -> String {
|
||||||
|
let bytes: [u8; 32] = rand::thread_rng().gen();
|
||||||
|
let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||||
|
format!("{id}.{secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a non-prefixed secret (for registration secrets that don't need lookup).
|
||||||
|
pub fn generate_secret() -> String {
|
||||||
|
let bytes: [u8; 32] = rand::thread_rng().gen();
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a token (or its secret part) with argon2.
|
||||||
|
pub fn hash_token(token: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
Ok(argon2.hash_password(token.as_bytes(), &salt)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a token against a stored argon2 hash.
|
||||||
|
pub fn verify_token(token: &str, hash: &str) -> bool {
|
||||||
|
let Ok(parsed_hash) = PasswordHash::new(hash) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(token.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a prefixed token into (id, secret).
|
||||||
|
/// Returns None if the token is not in `id.secret` format.
|
||||||
|
pub fn split_token(token: &str) -> Option<(&str, &str)> {
|
||||||
|
token.split_once('.')
|
||||||
|
}
|
||||||
167
src/cache.rs
Normal file
167
src/cache.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use sea_orm::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::entity::{links, resources};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CachedLink {
|
||||||
|
pub rel: String,
|
||||||
|
pub href: Option<String>,
|
||||||
|
pub link_type: Option<String>,
|
||||||
|
pub titles: Option<String>,
|
||||||
|
pub properties: Option<String>,
|
||||||
|
pub template: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CachedResource {
|
||||||
|
pub subject: String,
|
||||||
|
pub aliases: Option<Vec<String>>,
|
||||||
|
pub properties: Option<serde_json::Value>,
|
||||||
|
pub links: Vec<CachedLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Cache {
|
||||||
|
inner: Arc<DashMap<String, CachedResource>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(DashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, resource_uri: &str) -> Option<CachedResource> {
|
||||||
|
self.inner.get(resource_uri).map(|r| r.value().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, resource_uri: String, resource: CachedResource) {
|
||||||
|
self.inner.insert(resource_uri, resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, resource_uri: &str) {
|
||||||
|
self.inner.remove(resource_uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all cache entries for the given resource URIs.
|
||||||
|
/// Callers should query the DB for all resource URIs belonging to a domain
|
||||||
|
/// before deleting, then pass them here. This handles non-acct: URI schemes.
|
||||||
|
pub fn remove_many(&self, resource_uris: &[String]) {
|
||||||
|
for uri in resource_uris {
|
||||||
|
self.inner.remove(uri.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all non-expired resources and links from DB into cache.
|
||||||
|
pub async fn hydrate(&self, db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let all_resources = resources::Entity::find().all(db).await?;
|
||||||
|
|
||||||
|
for resource in all_resources {
|
||||||
|
let resource_links = links::Entity::find()
|
||||||
|
.filter(links::Column::ResourceId.eq(&resource.id))
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(links::Column::ExpiresAt.is_null())
|
||||||
|
.add(links::Column::ExpiresAt.gt(now)),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if resource_links.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached = CachedResource {
|
||||||
|
subject: resource.resource_uri.clone(),
|
||||||
|
aliases: resource
|
||||||
|
.aliases
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|a| serde_json::from_str(a).ok()),
|
||||||
|
properties: resource
|
||||||
|
.properties
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| serde_json::from_str(p).ok()),
|
||||||
|
links: resource_links
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| CachedLink {
|
||||||
|
rel: l.rel,
|
||||||
|
href: l.href,
|
||||||
|
link_type: l.link_type,
|
||||||
|
titles: l.titles,
|
||||||
|
properties: l.properties,
|
||||||
|
template: l.template,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.set(resource.resource_uri, cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild cache entry for a single resource from DB.
|
||||||
|
pub async fn refresh_resource(
|
||||||
|
&self,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
resource_uri: &str,
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
|
||||||
|
let resource = resources::Entity::find()
|
||||||
|
.filter(resources::Column::ResourceUri.eq(resource_uri))
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(resource) = resource else {
|
||||||
|
self.remove(resource_uri);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let resource_links = links::Entity::find()
|
||||||
|
.filter(links::Column::ResourceId.eq(&resource.id))
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(links::Column::ExpiresAt.is_null())
|
||||||
|
.add(links::Column::ExpiresAt.gt(now)),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if resource_links.is_empty() {
|
||||||
|
self.remove(resource_uri);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached = CachedResource {
|
||||||
|
subject: resource.resource_uri.clone(),
|
||||||
|
aliases: resource
|
||||||
|
.aliases
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|a| serde_json::from_str(a).ok()),
|
||||||
|
properties: resource
|
||||||
|
.properties
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| serde_json::from_str(p).ok()),
|
||||||
|
links: resource_links
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| CachedLink {
|
||||||
|
rel: l.rel,
|
||||||
|
href: l.href,
|
||||||
|
link_type: l.link_type,
|
||||||
|
titles: l.titles,
|
||||||
|
properties: l.properties,
|
||||||
|
template: l.template,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.set(resource.resource_uri, cached);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/challenge.rs
Normal file
25
src/challenge.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Trait for verifying domain ownership challenges (DNS-01 and HTTP-01).
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ChallengeVerifier: Send + Sync + std::fmt::Debug {
|
||||||
|
async fn verify_dns01(&self, domain: &str, expected_token: &str) -> Result<bool, String>;
|
||||||
|
async fn verify_http01(&self, domain: &str, expected_token: &str) -> Result<bool, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production implementation using real DNS and HTTP lookups.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RealChallengeVerifier;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChallengeVerifier for RealChallengeVerifier {
|
||||||
|
async fn verify_dns01(&self, _domain: &str, _expected_token: &str) -> Result<bool, String> {
|
||||||
|
// TODO: implement with hickory-resolver in a later task
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_http01(&self, _domain: &str, _expected_token: &str) -> Result<bool, String> {
|
||||||
|
// TODO: implement with reqwest in a later task
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/entity/domains.rs
Normal file
47
src/entity/domains.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "domains")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub domain: String,
|
||||||
|
pub owner_token_hash: String,
|
||||||
|
pub registration_secret: String,
|
||||||
|
pub challenge_type: String,
|
||||||
|
pub challenge_token: Option<String>,
|
||||||
|
pub verified: bool,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub verified_at: Option<chrono::NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::resources::Entity")]
|
||||||
|
Resources,
|
||||||
|
#[sea_orm(has_many = "super::service_tokens::Entity")]
|
||||||
|
ServiceTokens,
|
||||||
|
#[sea_orm(has_many = "super::links::Entity")]
|
||||||
|
Links,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::resources::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Resources.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::service_tokens::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ServiceTokens.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::links::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Links.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
63
src/entity/links.rs
Normal file
63
src/entity/links.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "links")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: String,
|
||||||
|
pub resource_id: String,
|
||||||
|
pub service_token_id: String,
|
||||||
|
pub domain_id: String,
|
||||||
|
pub rel: String,
|
||||||
|
pub href: Option<String>,
|
||||||
|
#[sea_orm(column_name = "type")]
|
||||||
|
pub link_type: Option<String>,
|
||||||
|
pub titles: Option<String>,
|
||||||
|
pub properties: Option<String>,
|
||||||
|
pub template: Option<String>,
|
||||||
|
pub ttl_seconds: Option<i32>,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub expires_at: Option<chrono::NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::resources::Entity",
|
||||||
|
from = "Column::ResourceId",
|
||||||
|
to = "super::resources::Column::Id"
|
||||||
|
)]
|
||||||
|
Resource,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::service_tokens::Entity",
|
||||||
|
from = "Column::ServiceTokenId",
|
||||||
|
to = "super::service_tokens::Column::Id"
|
||||||
|
)]
|
||||||
|
ServiceToken,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::domains::Entity",
|
||||||
|
from = "Column::DomainId",
|
||||||
|
to = "super::domains::Column::Id"
|
||||||
|
)]
|
||||||
|
Domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::resources::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Resource.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::service_tokens::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ServiceToken.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::domains::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Domain.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
4
src/entity/mod.rs
Normal file
4
src/entity/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod domains;
|
||||||
|
pub mod links;
|
||||||
|
pub mod resources;
|
||||||
|
pub mod service_tokens;
|
||||||
41
src/entity/resources.rs
Normal file
41
src/entity/resources.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "resources")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: String,
|
||||||
|
pub domain_id: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub resource_uri: String,
|
||||||
|
pub aliases: Option<String>,
|
||||||
|
pub properties: Option<String>,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::domains::Entity",
|
||||||
|
from = "Column::DomainId",
|
||||||
|
to = "super::domains::Column::Id"
|
||||||
|
)]
|
||||||
|
Domain,
|
||||||
|
#[sea_orm(has_many = "super::links::Entity")]
|
||||||
|
Links,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::domains::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Domain.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::links::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Links.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
41
src/entity/service_tokens.rs
Normal file
41
src/entity/service_tokens.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "service_tokens")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: String,
|
||||||
|
pub domain_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub allowed_rels: String,
|
||||||
|
pub resource_pattern: String,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub revoked_at: Option<chrono::NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::domains::Entity",
|
||||||
|
from = "Column::DomainId",
|
||||||
|
to = "super::domains::Column::Id"
|
||||||
|
)]
|
||||||
|
Domain,
|
||||||
|
#[sea_orm(has_many = "super::links::Entity")]
|
||||||
|
Links,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::domains::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Domain.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::links::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Links.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
@ -1,2 +1,7 @@
|
||||||
|
pub mod auth;
|
||||||
|
pub mod cache;
|
||||||
|
pub mod challenge;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod entity;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod state;
|
||||||
|
|
|
||||||
14
src/state.rs
Normal file
14
src/state.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::cache::Cache;
|
||||||
|
use crate::challenge::ChallengeVerifier;
|
||||||
|
use crate::config::Settings;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub cache: Cache,
|
||||||
|
pub settings: Arc<Settings>,
|
||||||
|
pub challenge_verifier: Arc<dyn ChallengeVerifier>,
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue