feat(api): add authentication middleware and token management routes

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 10:00:38 +00:00 committed by Jon Seager
parent 0b9cfefce5
commit 6d0ee869f7
No known key found for this signature in database
10 changed files with 264 additions and 28 deletions

View file

@ -28,8 +28,7 @@ pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Re
let username = username.trim(); let username = username.trim();
// Prompt for password (without echo) // Prompt for password (without echo)
let password = rpassword::prompt_password("Password: ") let password = rpassword::prompt_password("Password: ").context("failed to read password")?;
.context("failed to read password")?;
// Create the token // Create the token
let token_response = client let token_response = client

View file

@ -46,7 +46,9 @@ impl<'a> TokensClient<'a> {
} }
pub async fn revoke(&self, id: &str) -> Result<Token> { pub async fn revoke(&self, id: &str) -> Result<Token> {
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?; let response = self.client.http_client().post(url).send().await?;

View file

@ -19,7 +19,12 @@ pub struct NewUser {
} }
impl User { impl User {
pub fn new(id: UserId, username: String, password_hash: String, created_at: DateTime<Utc>) -> Self { pub fn new(
id: UserId,
username: String,
password_hash: String,
created_at: DateTime<Utc>,
) -> Self {
Self { Self {
id, id,
username, username,

View file

@ -1,7 +1,7 @@
use anyhow::Result; use anyhow::Result;
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
}; };
use base64::{Engine as _, engine::general_purpose}; use base64::{Engine as _, engine::general_purpose};
use rand::{RngCore, rngs::OsRng}; use rand::{RngCore, rngs::OsRng};
@ -11,12 +11,12 @@ use sha2::{Digest, Sha256};
pub fn hash_password(password: &str) -> Result<String> { pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); let argon2 = Argon2::default();
let password_hash = argon2 let password_hash = argon2
.hash_password(password.as_bytes(), &salt) .hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("failed to hash password: {}", e))? .map_err(|e| anyhow::anyhow!("failed to hash password: {}", e))?
.to_string(); .to_string();
Ok(password_hash) Ok(password_hash)
} }
@ -24,9 +24,9 @@ pub fn hash_password(password: &str) -> Result<String> {
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> { pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash) let parsed_hash = PasswordHash::new(password_hash)
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to parse password hash: {}", e))?;
let argon2 = Argon2::default(); let argon2 = Argon2::default();
match argon2.verify_password(password.as_bytes(), &parsed_hash) { match argon2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true), Ok(()) => Ok(true),
Err(_) => Ok(false), Err(_) => Ok(false),
@ -57,7 +57,7 @@ mod tests {
fn test_password_hashing() { fn test_password_hashing() {
let password = "test_password_123"; let password = "test_password_123";
let hash = hash_password(password).unwrap(); let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap()); assert!(verify_password(password, &hash).unwrap());
assert!(!verify_password("wrong_password", &hash).unwrap()); assert!(!verify_password("wrong_password", &hash).unwrap());
} }
@ -67,10 +67,10 @@ mod tests {
let password = "test_password_123"; let password = "test_password_123";
let hash1 = hash_password(password).unwrap(); let hash1 = hash_password(password).unwrap();
let hash2 = hash_password(password).unwrap(); let hash2 = hash_password(password).unwrap();
// Different salts should produce different hashes // Different salts should produce different hashes
assert_ne!(hash1, hash2); assert_ne!(hash1, hash2);
// But both should verify the same password // But both should verify the same password
assert!(verify_password(password, &hash1).unwrap()); assert!(verify_password(password, &hash1).unwrap());
assert!(verify_password(password, &hash2).unwrap()); assert!(verify_password(password, &hash2).unwrap());
@ -80,10 +80,10 @@ mod tests {
fn test_token_generation() { fn test_token_generation() {
let token1 = generate_token().unwrap(); let token1 = generate_token().unwrap();
let token2 = generate_token().unwrap(); let token2 = generate_token().unwrap();
// Tokens should be different // Tokens should be different
assert_ne!(token1, token2); assert_ne!(token1, token2);
// Tokens should be base64 encoded (at least 40 chars for 32 bytes) // Tokens should be base64 encoded (at least 40 chars for 32 bytes)
assert!(token1.len() >= 40); assert!(token1.len() >= 40);
assert!(token2.len() >= 40); assert!(token2.len() >= 40);
@ -94,10 +94,10 @@ mod tests {
let token = "test_token_12345"; let token = "test_token_12345";
let hash1 = hash_token(token); let hash1 = hash_token(token);
let hash2 = hash_token(token); let hash2 = hash_token(token);
// Same token should produce same hash // Same token should produce same hash
assert_eq!(hash1, hash2); assert_eq!(hash1, hash2);
// Different token should produce different hash // Different token should produce different hash
let different_token = "different_token"; let different_token = "different_token";
let hash3 = hash_token(different_token); let hash3 = hash_token(different_token);

View file

@ -32,7 +32,8 @@ impl SqlUserRepository {
#[async_trait] #[async_trait]
impl UserRepository for SqlUserRepository { impl UserRepository for SqlUserRepository {
async fn insert(&self, user: User) -> Result<User, RepositoryError> { async fn insert(&self, user: User) -> Result<User, RepositoryError> {
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) sqlx::query(query)
.bind(&user.id) .bind(&user.id)

98
src/server/auth.rs Normal file
View file

@ -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<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthenticatedUser>()
.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<AppState>,
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<AppState>,
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<User> {
// Extract token from Authorization header
let auth_header = request.headers().get(header::AUTHORIZATION)?;
let auth_str = auth_header.to_str().ok()?;
// Check for "Bearer <token>" 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::<AuthenticatedUser>()
.map(|auth| &auth.0)
}

View file

@ -1,3 +1,4 @@
pub mod auth;
pub mod errors; pub mod errors;
pub mod routes; pub mod routes;
pub mod server; pub mod server;

View file

@ -2,6 +2,7 @@ pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod support; pub mod support;
pub mod timeline; pub mod timeline;
pub mod tokens;
use askama::Template; use askama::Template;
use axum::http::StatusCode; use axum::http::StatusCode;
@ -15,9 +16,10 @@ use crate::presentation::templates::render_template;
pub fn app_router(state: AppState) -> axum::Router { pub fn app_router(state: AppState) -> axum::Router {
let api_routes = axum::Router::new() let api_routes = axum::Router::new()
// Public API routes
.route( .route(
"/roasters", "/roasters",
post(roasters::create_roaster).get(roasters::list_roasters), get(roasters::list_roasters).post(roasters::create_roaster),
) )
.route( .route(
"/roasters/:id", "/roasters/:id",
@ -27,12 +29,17 @@ pub fn app_router(state: AppState) -> axum::Router {
) )
.route( .route(
"/roasts", "/roasts",
post(roasts::create_roast).get(roasts::list_roasts), get(roasts::list_roasts).post(roasts::create_roast),
) )
.route( .route(
"/roasts/:id", "/roasts/:id",
get(roasts::get_roast).delete(roasts::delete_roast), 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() axum::Router::new()
.route("/", get(root_redirect)) .route("/", get(root_redirect))

110
src/server/routes/tokens.rs Normal file
View file

@ -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<AppState>,
Json(payload): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, 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<AppState>,
auth_user: AuthenticatedUser,
) -> Result<Json<Vec<Token>>, 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<AppState>,
auth_user: AuthenticatedUser,
Path(token_id): Path<String>,
) -> Result<Json<Token>, 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))
}

View file

@ -9,7 +9,9 @@ use tokio::signal;
use tracing::info; use tracing::info;
use crate::domain::ids::generate_id; 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::domain::users::User;
use crate::infrastructure::auth::hash_password; use crate::infrastructure::auth::hash_password;
use crate::infrastructure::database::Database; 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 roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
let roast_repo = Arc::new(SqlRoastRepository::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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let user_repo: Arc<dyn UserRepository> = Arc::new(SqlUserRepository::new(database.clone_pool())); let user_repo: Arc<dyn UserRepository> =
let token_repo: Arc<dyn TokenRepository> = Arc::new(SqlTokenRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> =
Arc::new(SqlTokenRepository::new(database.clone_pool()));
// Bootstrap admin user if no users exist // Bootstrap admin user if no users exist
bootstrap_admin_user(&user_repo, config.admin_password).await?; 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) let listener = TcpListener::bind(config.bind_address)
.await .await
@ -93,7 +103,9 @@ async fn bootstrap_admin_user(
admin_password: Option<String>, admin_password: Option<String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// Check if any users exist // 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")?; .context("failed to check if users exist")?;
if users_exist { if users_exist {
@ -111,8 +123,7 @@ async fn bootstrap_admin_user(
info!("No users found. Creating admin user..."); info!("No users found. Creating admin user...");
let password_hash = hash_password(&password) let password_hash = hash_password(&password).context("failed to hash admin password")?;
.context("failed to hash admin password")?;
let admin_user = User::new( let admin_user = User::new(
generate_id(), generate_id(),
@ -121,7 +132,9 @@ async fn bootstrap_admin_user(
Utc::now(), Utc::now(),
); );
user_repo.insert(admin_user).await user_repo
.insert(admin_user)
.await
.context("failed to create admin user")?; .context("failed to create admin user")?;
info!("Admin user created successfully"); info!("Admin user created successfully");