diff --git a/src/cli/tokens.rs b/src/cli/tokens.rs index 8b2e6f5..ca01c51 100644 --- a/src/cli/tokens.rs +++ b/src/cli/tokens.rs @@ -28,8 +28,7 @@ pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Re let username = username.trim(); // Prompt for password (without echo) - let password = rpassword::prompt_password("Password: ") - .context("failed to read password")?; + let password = rpassword::prompt_password("Password: ").context("failed to read password")?; // Create the token let token_response = client diff --git a/src/client/tokens.rs b/src/client/tokens.rs index 37cfd69..446636b 100644 --- a/src/client/tokens.rs +++ b/src/client/tokens.rs @@ -46,7 +46,9 @@ impl<'a> TokensClient<'a> { } pub async fn revoke(&self, id: &str) -> Result { - let url = self.client.endpoint(&format!("api/v1/tokens/{}/revoke", id))?; + let url = self + .client + .endpoint(&format!("api/v1/tokens/{}/revoke", id))?; let response = self.client.http_client().post(url).send().await?; diff --git a/src/domain/users.rs b/src/domain/users.rs index 5b86cf5..3ff0284 100644 --- a/src/domain/users.rs +++ b/src/domain/users.rs @@ -19,7 +19,12 @@ pub struct NewUser { } impl User { - pub fn new(id: UserId, username: String, password_hash: String, created_at: DateTime) -> Self { + pub fn new( + id: UserId, + username: String, + password_hash: String, + created_at: DateTime, + ) -> Self { Self { id, username, diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index af2cb56..5a8c863 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -1,7 +1,7 @@ use anyhow::Result; use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, }; use base64::{Engine as _, engine::general_purpose}; use rand::{RngCore, rngs::OsRng}; @@ -11,12 +11,12 @@ use sha2::{Digest, Sha256}; pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - + let password_hash = argon2 .hash_password(password.as_bytes(), &salt) .map_err(|e| anyhow::anyhow!("failed to hash password: {}", e))? .to_string(); - + Ok(password_hash) } @@ -24,9 +24,9 @@ pub fn hash_password(password: &str) -> Result { pub fn verify_password(password: &str, password_hash: &str) -> Result { let parsed_hash = PasswordHash::new(password_hash) .map_err(|e| anyhow::anyhow!("failed to parse password hash: {}", e))?; - + let argon2 = Argon2::default(); - + match argon2.verify_password(password.as_bytes(), &parsed_hash) { Ok(()) => Ok(true), Err(_) => Ok(false), @@ -57,7 +57,7 @@ mod tests { fn test_password_hashing() { let password = "test_password_123"; let hash = hash_password(password).unwrap(); - + assert!(verify_password(password, &hash).unwrap()); assert!(!verify_password("wrong_password", &hash).unwrap()); } @@ -67,10 +67,10 @@ mod tests { let password = "test_password_123"; let hash1 = hash_password(password).unwrap(); let hash2 = hash_password(password).unwrap(); - + // Different salts should produce different hashes assert_ne!(hash1, hash2); - + // But both should verify the same password assert!(verify_password(password, &hash1).unwrap()); assert!(verify_password(password, &hash2).unwrap()); @@ -80,10 +80,10 @@ mod tests { fn test_token_generation() { let token1 = generate_token().unwrap(); let token2 = generate_token().unwrap(); - + // Tokens should be different assert_ne!(token1, token2); - + // Tokens should be base64 encoded (at least 40 chars for 32 bytes) assert!(token1.len() >= 40); assert!(token2.len() >= 40); @@ -94,10 +94,10 @@ mod tests { let token = "test_token_12345"; let hash1 = hash_token(token); let hash2 = hash_token(token); - + // Same token should produce same hash assert_eq!(hash1, hash2); - + // Different token should produce different hash let different_token = "different_token"; let hash3 = hash_token(different_token); diff --git a/src/infrastructure/repositories/users.rs b/src/infrastructure/repositories/users.rs index ccd0f36..1fc2170 100644 --- a/src/infrastructure/repositories/users.rs +++ b/src/infrastructure/repositories/users.rs @@ -32,7 +32,8 @@ impl SqlUserRepository { #[async_trait] impl UserRepository for SqlUserRepository { async fn insert(&self, user: User) -> Result { - let query = "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)"; + let query = + "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)"; sqlx::query(query) .bind(&user.id) diff --git a/src/server/auth.rs b/src/server/auth.rs new file mode 100644 index 0000000..8f07aba --- /dev/null +++ b/src/server/auth.rs @@ -0,0 +1,98 @@ +use axum::{ + async_trait, + extract::{FromRequestParts, Request, State}, + http::{StatusCode, header, request::Parts}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::domain::users::User; +use crate::infrastructure::auth::hash_token; +use crate::server::server::AppState; + +/// Extension type to carry authenticated user through request handlers +#[derive(Clone)] +pub struct AuthenticatedUser(pub User); + +#[async_trait] +impl FromRequestParts for AuthenticatedUser +where + S: Send + Sync, +{ + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(StatusCode::UNAUTHORIZED) + } +} + +/// Middleware that optionally extracts authentication from the request +/// Sets AuthenticatedUser extension if valid token is found +pub async fn optional_auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + if let Some(user) = extract_user_from_request(&state, &request).await { + request.extensions_mut().insert(AuthenticatedUser(user)); + } + next.run(request).await +} + +/// Middleware that requires authentication +/// Returns 401 if no valid token is found +pub async fn require_auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + match extract_user_from_request(&state, &request).await { + Some(user) => { + request.extensions_mut().insert(AuthenticatedUser(user)); + next.run(request).await + } + None => StatusCode::UNAUTHORIZED.into_response(), + } +} + +async fn extract_user_from_request(state: &AppState, request: &Request) -> Option { + // Extract token from Authorization header + let auth_header = request.headers().get(header::AUTHORIZATION)?; + let auth_str = auth_header.to_str().ok()?; + + // Check for "Bearer " format + let token = auth_str.strip_prefix("Bearer ")?; + + // Hash the token to look it up in the database + let token_hash = hash_token(token); + + // Look up the token + let token_record = state.token_repo.get_by_token_hash(&token_hash).await.ok()?; + + // Check if token is revoked + if token_record.is_revoked() { + return None; + } + + // Update last used timestamp (fire and forget - don't block on this) + let token_repo = state.token_repo.clone(); + let token_id = token_record.id.clone(); + tokio::spawn(async move { + let _ = token_repo.update_last_used(token_id).await; + }); + + // Get the user + state.user_repo.get(token_record.user_id).await.ok() +} + +/// Helper to extract authenticated user from request extensions +pub fn get_authenticated_user(request: &Request) -> Option<&User> { + request + .extensions() + .get::() + .map(|auth| &auth.0) +} diff --git a/src/server/mod.rs b/src/server/mod.rs index f54cb6b..b396f8b 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod errors; pub mod routes; pub mod server; diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index af8de2e..54ef687 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -2,6 +2,7 @@ pub mod roasters; pub mod roasts; pub mod support; pub mod timeline; +pub mod tokens; use askama::Template; use axum::http::StatusCode; @@ -15,9 +16,10 @@ use crate::presentation::templates::render_template; pub fn app_router(state: AppState) -> axum::Router { let api_routes = axum::Router::new() + // Public API routes .route( "/roasters", - post(roasters::create_roaster).get(roasters::list_roasters), + get(roasters::list_roasters).post(roasters::create_roaster), ) .route( "/roasters/:id", @@ -27,12 +29,17 @@ pub fn app_router(state: AppState) -> axum::Router { ) .route( "/roasts", - post(roasts::create_roast).get(roasts::list_roasts), + get(roasts::list_roasts).post(roasts::create_roast), ) .route( "/roasts/:id", get(roasts::get_roast).delete(roasts::delete_roast), - ); + ) + .route( + "/tokens", + post(tokens::create_token).get(tokens::list_tokens), + ) + .route("/tokens/:id/revoke", post(tokens::revoke_token)); axum::Router::new() .route("/", get(root_redirect)) diff --git a/src/server/routes/tokens.rs b/src/server/routes/tokens.rs new file mode 100644 index 0000000..daafcd7 --- /dev/null +++ b/src/server/routes/tokens.rs @@ -0,0 +1,110 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::domain::ids::generate_id; +use crate::domain::tokens::Token; +use crate::infrastructure::auth::{generate_token, hash_token, verify_password}; +use crate::server::auth::AuthenticatedUser; +use crate::server::server::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateTokenRequest { + pub username: String, + pub password: String, + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct CreateTokenResponse { + pub id: String, + pub name: String, + pub token: String, +} + +pub async fn create_token( + State(state): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Verify username and password + let user = state + .user_repo + .get_by_username(&payload.username) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + let password_valid = verify_password(&payload.password, &user.password_hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !password_valid { + return Err(StatusCode::UNAUTHORIZED); + } + + // Generate new token + let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let token_hash = hash_token(&token_value); + + let token = Token::new( + generate_id(), + user.id.clone(), + token_hash, + payload.name.clone(), + Utc::now(), + ); + + // Store token + let stored_token = state + .token_repo + .insert(token) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(CreateTokenResponse { + id: stored_token.id, + name: stored_token.name, + token: token_value, + })) +} + +pub async fn list_tokens( + State(state): State, + auth_user: AuthenticatedUser, +) -> Result>, StatusCode> { + let tokens = state + .token_repo + .list_by_user(auth_user.0.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(tokens)) +} + +pub async fn revoke_token( + State(state): State, + auth_user: AuthenticatedUser, + Path(token_id): Path, +) -> Result, StatusCode> { + // Get the token to ensure it exists and belongs to the user + let token = state + .token_repo + .get(token_id.clone()) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Ensure the token belongs to the authenticated user + if token.user_id != auth_user.0.id { + return Err(StatusCode::FORBIDDEN); + } + + // Revoke the token + let revoked_token = state + .token_repo + .revoke(token_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(revoked_token)) +} diff --git a/src/server/server.rs b/src/server/server.rs index a4255fd..ae38a92 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -9,7 +9,9 @@ use tokio::signal; use tracing::info; use crate::domain::ids::generate_id; -use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository}; +use crate::domain::repositories::{ + RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository, +}; use crate::domain::users::User; use crate::infrastructure::auth::hash_password; use crate::infrastructure::database::Database; @@ -62,13 +64,21 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); - let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); - let token_repo: Arc = Arc::new(SqlTokenRepository::new(database.clone_pool())); + let user_repo: Arc = + Arc::new(SqlUserRepository::new(database.clone_pool())); + let token_repo: Arc = + Arc::new(SqlTokenRepository::new(database.clone_pool())); // Bootstrap admin user if no users exist bootstrap_admin_user(&user_repo, config.admin_password).await?; - let state = AppState::new(roaster_repo, roast_repo, timeline_repo, user_repo, token_repo); + let state = AppState::new( + roaster_repo, + roast_repo, + timeline_repo, + user_repo, + token_repo, + ); let listener = TcpListener::bind(config.bind_address) .await @@ -93,7 +103,9 @@ async fn bootstrap_admin_user( admin_password: Option, ) -> anyhow::Result<()> { // Check if any users exist - let users_exist = user_repo.exists().await + let users_exist = user_repo + .exists() + .await .context("failed to check if users exist")?; if users_exist { @@ -111,8 +123,7 @@ async fn bootstrap_admin_user( info!("No users found. Creating admin user..."); - let password_hash = hash_password(&password) - .context("failed to hash admin password")?; + let password_hash = hash_password(&password).context("failed to hash admin password")?; let admin_user = User::new( generate_id(), @@ -121,7 +132,9 @@ async fn bootstrap_admin_user( Utc::now(), ); - user_repo.insert(admin_user).await + user_repo + .insert(admin_user) + .await .context("failed to create admin user")?; info!("Admin user created successfully");