diff --git a/README.md b/README.md index 62ab1f6..7dca620 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,152 @@ # Phomnemic +A phonemic encoding/decoding system with dictionary management and REST API. + +## Configure and Run + +### Prerequisites + +- Rust 1.70 or later +- SQLite (for local database) +- Firebase Auth (optional, for JWT authentication) + +### Installation + +```bash +cargo build --release +``` + +### Configuration + +Create a `config.toml` file in the project root: + +```toml +[listen] +host = "0.0.0.0" +port = 3000 + +log_level = "info" + +[auth] +firebase_project_id = "your-firebase-project-id" +firebase_emulator_url = "http://localhost:9099" +api_tokens = ["your-api-key-1", "your-api-key-2"] + +[database] +url = "sqlite:app.db" +``` + +#### Authentication Options + +The application supports two authentication methods: + +1. **Firebase JWT Authentication** (for frontend clients) + - Set `firebase_project_id` to your Firebase project ID + - Or use `firebase_emulator_url` for local development with Firebase Auth Emulator + +2. **API Key Authentication** (for server-to-server or CLI access) + - Add valid API keys to the `api_tokens` array + - Send requests with `X-API-Key` header containing your API key + +### Running with Firebase Auth Emulator + +1. Start the Firebase Auth Emulator: + ```bash + firebase emulators:start --only auth + ``` + The emulator runs on port 9099 by default. + +2. Configure the application to use the emulator: + ```toml + [auth] + firebase_emulator_url = "http://localhost:9099" + api_tokens = ["dev-api-key"] + ``` + +3. Get a test token from the emulator: + ```bash + # Using curl + curl -X POST "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password", + "returnSecureToken": true + }' + ``` + +4. Make authenticated requests: + ```bash + curl -X GET "http://localhost:3000/api/v1/health" \ + -H "Authorization: Bearer " + ``` + +### Running with API Keys + +1. Configure API keys in `config.toml`: + ```toml + [auth] + api_tokens = ["my-secret-api-key"] + ``` + +2. Make authenticated requests: + ```bash + curl -X GET "http://localhost:3000/api/v1/health" \ + -H "X-API-Key: my-secret-api-key" + ``` + +### Starting the Server + +```bash +cargo run --release -- listen +``` + +Or with custom options: + +```bash +cargo run --release -- listen --host 127.0.0.1 --port 8080 +``` + +### API Endpoints + +#### Health Check +```bash +GET /api/v1/health +``` + +#### Dictionary Management +```bash +GET /api/v1/dicts +GET /api/v1/dicts/:name +``` + +#### Major System Encoding/Decoding +```bash +POST /api/v1/major/encode +POST /api/v1/major/decode +``` + +#### Authentication +```bash +POST /api/v1/auth/login +``` + +### Development + +Run in development mode with auto-reload: + +```bash +cargo run -- listen +``` + +### Testing + +Run tests: + +```bash +cargo test +``` + +### License + +MIT diff --git a/apps/app_api/config.toml.example b/apps/app_api/config.toml.example index 0309283..8560164 100644 --- a/apps/app_api/config.toml.example +++ b/apps/app_api/config.toml.example @@ -2,4 +2,12 @@ host = "0.0.0.0" port = 3000 -log_level = "info" \ No newline at end of file +log_level = "info" + +[auth] +firebase_project_id = "your-firebase-project-id" +firebase_emulator_url = "http://localhost:9099" +api_tokens = ["your-api-key-1", "your-api-key-2"] + +[database] +url = "sqlite:app.db" \ No newline at end of file diff --git a/apps/app_api/src/api.rs b/apps/app_api/src/api.rs index 4370f79..18c3dff 100644 --- a/apps/app_api/src/api.rs +++ b/apps/app_api/src/api.rs @@ -4,6 +4,6 @@ use crate::state::AppState; use axum::Router; use std::sync::Arc; -pub fn routes() -> Router> { - Router::new().nest("/api/v1", v1::routes()) +pub fn routes(state: Arc) -> Router> { + Router::new().nest("/api/v1", v1::routes(state)) } diff --git a/apps/app_api/src/api/v1.rs b/apps/app_api/src/api/v1.rs index 032d2f2..33690bf 100644 --- a/apps/app_api/src/api/v1.rs +++ b/apps/app_api/src/api/v1.rs @@ -4,13 +4,60 @@ pub mod health; pub mod major; use crate::state::AppState; -use axum::Router; +use axum::{ + Router, extract::Request, extract::State, http::StatusCode, middleware::Next, + response::Response, +}; use std::sync::Arc; -pub fn routes() -> Router> { +pub fn routes(state: Arc) -> Router> { Router::new() .nest("/health", health::routes()) + .nest("/auth", auth::routes()) .nest("/dicts", dictionary::routes()) .nest("/major", major::routes()) - .nest("/auth", auth::routes()) + .route_layer(axum::middleware::from_fn_with_state( + state, + |state: State>, request: Request, next: Next| async move { + auth_middleware_inner(state, request, next).await + }, + )) +} + +async fn auth_middleware_inner( + state: State>, + mut request: Request, + next: Next, +) -> Response { + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()); + + let api_key_header = request + .headers() + .get("X-API-Key") + .and_then(|h| h.to_str().ok()); + + let token = if let Some(header) = auth_header { + header.to_string() + } else if let Some(key) = api_key_header { + key.to_string() + } else { + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("Missing authorization header or API key".into()) + .unwrap(); + }; + + match state.0.dependencies.auth_service.authenticate(&token).await { + Ok(claims) => { + request.extensions_mut().insert(claims); + next.run(request).await + } + Err(_) => Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("Unauthorized".into()) + .unwrap(), + } } diff --git a/apps/app_api/src/commands/listen.rs b/apps/app_api/src/commands/listen.rs index 128a481..2ee2262 100644 --- a/apps/app_api/src/commands/listen.rs +++ b/apps/app_api/src/commands/listen.rs @@ -31,6 +31,7 @@ impl Configurable for ListenCmd { builder: ConfigBuilder, ) -> Result> { builder + .set_default("log_level", defaults::LOG_LEVEL)? .set_default("listen.host", defaults::LISTEN_HOST)? .set_default("listen.port", defaults::LISTEN_PORT) .map_err(Into::into) @@ -60,12 +61,13 @@ impl Executable for ListenCmd { .as_ref() .ok_or_else(|| anyhow::anyhow!("Listen config missing"))?; - let app = router::create_router().await?; + let app = router::create_router(config).await?; let addr = format!("{}:{}", listen_config.host, listen_config.port); let listener = TcpListener::bind(&addr).await?; info!("Starting server on {}", addr); + // axum::serve(listener, app.into_make_service()).await?; axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; @@ -103,6 +105,7 @@ async fn shutdown_signal() { mod defaults { use const_format::formatcp; + pub const LOG_LEVEL: &str = "info"; pub const LISTEN_HOST: &str = "0.0.0.0"; pub const LISTEN_PORT: u16 = 3000; pub const HELP_LISTEN_HOST: &str = formatcp!("Host address [default: {}]", LISTEN_HOST); diff --git a/apps/app_api/src/config/auth.rs b/apps/app_api/src/config/auth.rs index 4ca0721..dee2101 100644 --- a/apps/app_api/src/config/auth.rs +++ b/apps/app_api/src/config/auth.rs @@ -3,6 +3,7 @@ use serde::Deserialize; #[derive(Debug, Deserialize, Clone)] pub struct AuthConfig { pub firebase_project_id: Option, + pub firebase_emulator_url: Option, pub api_tokens: Vec, } @@ -10,6 +11,7 @@ impl Default for AuthConfig { fn default() -> Self { Self { firebase_project_id: None, + firebase_emulator_url: None, api_tokens: Vec::new(), } } diff --git a/apps/app_api/src/dependencies.rs b/apps/app_api/src/dependencies.rs index b80c6d4..ee9262b 100644 --- a/apps/app_api/src/dependencies.rs +++ b/apps/app_api/src/dependencies.rs @@ -18,18 +18,23 @@ impl AppDependencies { pub async fn new( database_url: &str, firebase_project_id: Option, + firebase_emulator_url: Option, + api_tokens: Vec, ) -> anyhow::Result { let repo_factory = Arc::new(SqliteDictRepository::new(database_url).await?); let dictionary_service = Arc::new(DictionaryService::new(repo_factory.clone())); - let jwt_auth: Arc = if let Some(project_id) = firebase_project_id { + let jwt_auth: Arc = if let Some(emulator_url) = firebase_emulator_url { + Arc::new(JwtAuthenticator::with_emulator(emulator_url)) + } else if let Some(project_id) = firebase_project_id { Arc::new(JwtAuthenticator::new(project_id)) } else { Arc::new(JwtAuthenticator::with_placeholder()) }; - let api_token_auth: Arc = Arc::new(ApiTokenAuthenticator::new()); + let api_token_authenticator = ApiTokenAuthenticator::with_tokens(api_tokens); + let api_token_auth: Arc = Arc::new(api_token_authenticator); let token_store: Arc = Arc::new(InMemoryTokenStore::new()); let auth_service = Arc::new(AuthService::new(jwt_auth, api_token_auth, token_store)); diff --git a/apps/app_api/src/error.rs b/apps/app_api/src/error.rs index 2044e65..95e1884 100644 --- a/apps/app_api/src/error.rs +++ b/apps/app_api/src/error.rs @@ -34,7 +34,6 @@ impl ErrorResponse { impl From for ErrorResponse { fn from(err: ServiceError) -> Self { - let message = err.to_string(); match &err { ServiceError::Repository(e) => ErrorResponse { error: "Repository error".to_string(), diff --git a/apps/app_api/src/main.rs b/apps/app_api/src/main.rs index d0596e4..cf28f90 100644 --- a/apps/app_api/src/main.rs +++ b/apps/app_api/src/main.rs @@ -4,7 +4,6 @@ mod commands; mod config; mod dependencies; mod error; -mod middleware; mod router; mod state; diff --git a/apps/app_api/src/middleware.rs b/apps/app_api/src/middleware.rs deleted file mode 100644 index 27e516a..0000000 --- a/apps/app_api/src/middleware.rs +++ /dev/null @@ -1,38 +0,0 @@ -pub mod auth; - -use crate::state::AppState; -use axum::{ - extract::{Request, State}, - middleware::Next, - response::Response, -}; -use std::sync::Arc; - -pub async fn auth_middleware( - State(state): State>, - mut request: Request, - next: Next, -) -> Result { - let auth_header = request - .headers() - .get("Authorization") - .and_then(|h| h.to_str().ok()); - - if let Some(token) = auth_header { - match state.dependencies.auth_service.authenticate(token).await { - Ok(claims) => { - request.extensions_mut().insert(claims); - Ok(next.run(request).await) - } - Err(_) => Err(Response::builder() - .status(401) - .body("Unauthorized".into()) - .unwrap()), - } - } else { - Err(Response::builder() - .status(401) - .body("Missing authorization header".into()) - .unwrap()) - } -} diff --git a/apps/app_api/src/middleware/auth.rs b/apps/app_api/src/middleware/auth.rs deleted file mode 100644 index 4b0611a..0000000 --- a/apps/app_api/src/middleware/auth.rs +++ /dev/null @@ -1,8 +0,0 @@ -use axum::http::HeaderMap; - -pub fn extract_token(headers: &HeaderMap) -> Option { - headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()) -} diff --git a/apps/app_api/src/router.rs b/apps/app_api/src/router.rs index eb1fbc3..f85c596 100644 --- a/apps/app_api/src/router.rs +++ b/apps/app_api/src/router.rs @@ -1,16 +1,25 @@ -use axum::Router; use std::sync::Arc; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use crate::api; +use crate::config::AppConfig; use crate::dependencies::AppDependencies; use crate::state::AppState; -pub async fn create_router() -> anyhow::Result { - let dependencies = AppDependencies::new("sqlite:app.db", None).await?; +pub async fn create_router(config: &AppConfig) -> anyhow::Result { + let database_url = &config.database.url; + + let dependencies = AppDependencies::new( + database_url, + config.auth.firebase_project_id.clone(), + config.auth.firebase_emulator_url.clone(), + config.auth.api_tokens.clone(), + ) + .await?; + let state = Arc::new(AppState::new(dependencies).await); - Ok(api::routes() + Ok(api::routes(state.clone()) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive())) diff --git a/config.toml b/config.toml index e8b4036..13e61f8 100644 --- a/config.toml +++ b/config.toml @@ -1 +1,13 @@ -log_level = "info" +[listen] +host = "0.0.0.0" +port = 3000 + +log_level = "trace" + +[auth] +firebase_project_id = "test-project" +firebase_emulator_url = "http://192.168.1.23:9099" +api_tokens = ["dev-api-key"] + +[database] +url = "sqlite:app.db" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index a62f2e5..27487b7 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -19,6 +19,8 @@ async-trait = "0.1" parking_lot = "0.12" sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] } futures = "0.3.31" +jsonwebtoken = "9.3" +reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] mockall = "0.14.0" diff --git a/lib/src/auth/infrastructure/jwt.rs b/lib/src/auth/infrastructure/jwt.rs index 599820f..c70488c 100644 --- a/lib/src/auth/infrastructure/jwt.rs +++ b/lib/src/auth/infrastructure/jwt.rs @@ -1,22 +1,153 @@ use crate::auth::domain::AuthClaims; use crate::auth::traits::Authenticator; use crate::common::errors::AuthError; +use jsonwebtoken::{ + Algorithm, DecodingKey, Validation, dangerous_insecure_decode, decode, decode_header, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FirebaseClaims { + iss: String, + aud: String, + auth_time: i64, + user_id: String, + sub: String, + iat: i64, + exp: i64, + email: Option, + email_verified: Option, + firebase: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FirebaseData { + identities: Option, + sign_in_provider: Option, + tenant: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct PublicKey { + n: String, + e: String, + kid: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct JwksResponse { + keys: Vec, +} + +#[derive(Clone)] +struct KeyCache { + keys: HashMap, + expires_at: i64, +} pub struct JwtAuthenticator { firebase_project_id: String, + issuer: String, + audience: String, + emulator_url: Option, + key_cache: Arc>>, } impl JwtAuthenticator { pub fn new(firebase_project_id: impl Into) -> Self { + let project_id = firebase_project_id.into(); + let issuer = format!("https://securetoken.google.com/{}", project_id); + Self { + firebase_project_id: project_id.clone(), + issuer, + audience: project_id, + emulator_url: None, + key_cache: Arc::new(RwLock::new(None)), + } + } + + pub fn with_emulator(emulator_url: impl Into) -> Self { Self { - firebase_project_id: firebase_project_id.into(), + firebase_project_id: "emulator".to_string(), + issuer: "https://securetoken.google.com/emulator".to_string(), + audience: "emulator".to_string(), + emulator_url: Some(emulator_url.into()), + key_cache: Arc::new(RwLock::new(None)), } } pub fn with_placeholder() -> Self { Self { firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + issuer: "https://securetoken.google.com/FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + audience: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + emulator_url: None, + key_cache: Arc::new(RwLock::new(None)), + } + } + + async fn get_public_keys(&self) -> Result, AuthError> { + let cache = self.key_cache.read().await; + if let Some(ref cached) = *cache { + let now = chrono::Utc::now().timestamp(); + if cached.expires_at > now { + return Ok(cached.keys.clone()); + } + } + drop(cache); + + let url = if let Some(ref emulator) = self.emulator_url { + format!("{}/.well-known/jwks.json", emulator.trim_end_matches('/')) + } else { + format!( + "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" + ) + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to build HTTP client: {}", e)) + })?; + + let response = client.get(&url).send().await.map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to fetch public keys: {}", e)) + })?; + + if !response.status().is_success() { + return Err(AuthError::AuthenticationFailed(format!( + "Failed to fetch public keys: HTTP {}", + response.status() + ))); + } + + let body = response.text().await.map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to read response: {}", e)) + })?; + + let jwks: JwksResponse = serde_json::from_str(&body) + .map_err(|e| AuthError::AuthenticationFailed(format!("Failed to parse JWKS: {}", e)))?; + + let mut keys = HashMap::new(); + for key in jwks.keys { + let decoding_key = DecodingKey::from_rsa_components(&key.n, &key.e).map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to create decoding key: {}", e)) + })?; + keys.insert(key.kid, decoding_key); } + + let expires_at = chrono::Utc::now().timestamp() + 3600; + let new_cache = KeyCache { keys, expires_at }; + + let mut cache = self.key_cache.write().await; + *cache = Some(new_cache); + + Ok(cache.as_ref().unwrap().keys.clone()) } } @@ -29,40 +160,53 @@ impl Authenticator for JwtAuthenticator { )); } - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return Err(AuthError::InvalidToken); - } + let header = decode_header(token).map_err(|e| { + tracing::debug!("Failed to decode header: {}", e.to_string()); + AuthError::InvalidToken + })?; - let claims = AuthClaims::new("firebase_user_123") - .with_email("user@example.com") - .with_role("user"); + let kid = header + .kid + .ok_or_else(|| AuthError::AuthenticationFailed("Token missing key ID".to_string()))?; - Ok(claims) - } -} + let public_keys = self.get_public_keys().await?; + let decoding_key = public_keys + .get(&kid) + .ok_or_else(|| AuthError::AuthenticationFailed("Unknown key ID".to_string()))?; -pub struct JwtConfig { - pub firebase_project_id: String, - pub issuer: String, - pub audience: String, -} + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&self.audience]); + validation.set_issuer(&[&self.issuer]); + validation.validate_exp = true; -impl JwtConfig { - pub fn new(firebase_project_id: String) -> Self { - let issuer = format!("https://securetoken.google.com/{}", firebase_project_id); - Self { - firebase_project_id: firebase_project_id.clone(), - issuer, - audience: firebase_project_id, + let token_data = + decode::(token, decoding_key, &validation).map_err(|e| { + AuthError::AuthenticationFailed(format!("Token validation failed: {}", e)) + })?; + + let mut claims = AuthClaims::new(token_data.claims.user_id) + .with_expiration(token_data.claims.exp) + .with_iat(token_data.claims.iat); + + if let Some(email) = token_data.claims.email { + claims = claims.with_email(email); } - } - pub fn placeholder() -> Self { - Self { - firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), - issuer: "https://securetoken.google.com/FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), - audience: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + if let Some(ref firebase) = token_data.claims.firebase { + if let Some(ref provider) = firebase.sign_in_provider { + claims = claims.with_role(format!("auth:{}", provider)); + } } + + claims = claims.with_role("authenticated"); + + Ok(claims) + } +} + +impl AuthClaims { + pub fn with_iat(mut self, iat: i64) -> Self { + self.iat = iat; + self } } diff --git a/lib/src/common.rs b/lib/src/common.rs index fd7d794..52acd32 100644 --- a/lib/src/common.rs +++ b/lib/src/common.rs @@ -2,6 +2,5 @@ pub mod entities; pub mod errors; pub mod traits; -pub use self::errors::{AuthError, ServiceError}; pub use self::traits::SystemDecoder; pub use self::traits::SystemEncoder; diff --git a/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs b/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs index 124e272..c75bc0c 100644 --- a/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs +++ b/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs @@ -6,7 +6,6 @@ use futures::stream::BoxStream; use sqlx::{Row, SqlitePool, sqlite::SqliteConnectOptions}; use std::collections::HashMap; use std::str::FromStr; -use std::sync::Arc; #[derive(sqlx::FromRow)] struct SqliteEntryDto { @@ -250,7 +249,7 @@ impl DictRepository for SqliteDictRepository { #[async_trait::async_trait] impl DictRepositoryFactory for SqliteDictRepository { async fn create(&self, dict_name: &str) -> Result, RepositoryError> { - let mut repo = Self { + let repo = Self { pool: self.pool.clone(), dict_name: dict_name.to_string(), };