feat(api): add authentication middleware and token management routes
Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
parent
0b9cfefce5
commit
6d0ee869f7
10 changed files with 264 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ impl<'a> TokensClient<'a> {
|
|||
}
|
||||
|
||||
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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ pub struct NewUser {
|
|||
}
|
||||
|
||||
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 {
|
||||
id,
|
||||
username,
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String> {
|
|||
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ impl SqlUserRepository {
|
|||
#[async_trait]
|
||||
impl UserRepository for SqlUserRepository {
|
||||
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)
|
||||
.bind(&user.id)
|
||||
|
|
|
|||
98
src/server/auth.rs
Normal file
98
src/server/auth.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod auth;
|
||||
pub mod errors;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
110
src/server/routes/tokens.rs
Normal file
110
src/server/routes/tokens.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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<dyn UserRepository> = Arc::new(SqlUserRepository::new(database.clone_pool()));
|
||||
let token_repo: Arc<dyn TokenRepository> = Arc::new(SqlTokenRepository::new(database.clone_pool()));
|
||||
let user_repo: Arc<dyn UserRepository> =
|
||||
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(&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<String>,
|
||||
) -> 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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue