Files
nora/nora-registry/src/auth.rs
devitway 402d2321ef feat: add RBAC (read/write/admin) and persistent audit log
- Add Role enum to tokens: Read, Write, Admin (default: Read)
- Enforce role-based access in auth middleware (read-only tokens blocked from PUT/POST/DELETE)
- Add role field to token create/list/verify API
- Add persistent audit log (append-only JSONL) for all registry operations
- Audit logging across all registries: docker, npm, maven, pypi, cargo, raw

DevITWay
2026-03-03 10:40:59 +00:00

460 lines
14 KiB
Rust

// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{
body::Body,
extract::State,
http::{header, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::AppState;
use crate::tokens::Role;
/// Htpasswd-based authentication
#[derive(Clone)]
pub struct HtpasswdAuth {
users: HashMap<String, String>, // username -> bcrypt hash
}
impl HtpasswdAuth {
/// Load users from htpasswd file
pub fn from_file(path: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
let mut users = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((username, hash)) = line.split_once(':') {
users.insert(username.to_string(), hash.to_string());
}
}
if users.is_empty() {
None
} else {
Some(Self { users })
}
}
/// Verify username and password
pub fn authenticate(&self, username: &str, password: &str) -> bool {
if let Some(hash) = self.users.get(username) {
bcrypt::verify(password, hash).unwrap_or(false)
} else {
false
}
}
/// Get list of usernames
pub fn list_users(&self) -> Vec<&str> {
self.users.keys().map(|s| s.as_str()).collect()
}
}
/// Check if path is public (no auth required)
fn is_public_path(path: &str) -> bool {
matches!(
path,
"/" | "/health"
| "/ready"
| "/metrics"
| "/v2/"
| "/v2"
| "/api/tokens"
| "/api/tokens/list"
| "/api/tokens/revoke"
) || path.starts_with("/ui")
|| path.starts_with("/api-docs")
|| path.starts_with("/api/ui")
}
/// Auth middleware - supports Basic auth and Bearer tokens
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
request: Request<Body>,
next: Next,
) -> Response {
// Skip auth if disabled
let auth = match &state.auth {
Some(auth) => auth,
None => return next.run(request).await,
};
// Skip auth for public endpoints
if is_public_path(request.uri().path()) {
return next.run(request).await;
}
// Extract Authorization header
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let auth_header = match auth_header {
Some(h) => h,
None => return unauthorized_response("Authentication required"),
};
// Try Bearer token first
if let Some(token) = auth_header.strip_prefix("Bearer ") {
if let Some(ref token_store) = state.tokens {
match token_store.verify_token(token) {
Ok((_user, role)) => {
let method = request.method().clone();
if (method == axum::http::Method::PUT
|| method == axum::http::Method::POST
|| method == axum::http::Method::DELETE
|| method == axum::http::Method::PATCH)
&& !role.can_write()
{
return (StatusCode::FORBIDDEN, "Read-only token").into_response();
}
return next.run(request).await;
}
Err(_) => return unauthorized_response("Invalid or expired token"),
}
} else {
return unauthorized_response("Token authentication not configured");
}
}
// Parse Basic auth
if !auth_header.starts_with("Basic ") {
return unauthorized_response("Basic or Bearer authentication required");
}
let encoded = &auth_header[6..];
let decoded = match STANDARD.decode(encoded) {
Ok(d) => d,
Err(_) => return unauthorized_response("Invalid credentials encoding"),
};
let credentials = match String::from_utf8(decoded) {
Ok(c) => c,
Err(_) => return unauthorized_response("Invalid credentials encoding"),
};
let (username, password) = match credentials.split_once(':') {
Some((u, p)) => (u, p),
None => return unauthorized_response("Invalid credentials format"),
};
// Verify credentials
if !auth.authenticate(username, password) {
return unauthorized_response("Invalid username or password");
}
// Auth successful
next.run(request).await
}
fn unauthorized_response(message: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
[
(header::WWW_AUTHENTICATE, "Basic realm=\"Nora\""),
(header::CONTENT_TYPE, "application/json"),
],
format!(r#"{{"error":"{}"}}"#, message),
)
.into_response()
}
/// Generate bcrypt hash for password (for CLI user management)
#[allow(dead_code)]
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
bcrypt::hash(password, bcrypt::DEFAULT_COST)
}
// Token management API routes
use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct CreateTokenRequest {
pub username: String,
pub password: String,
#[serde(default = "default_ttl")]
pub ttl_days: u64,
pub description: Option<String>,
#[serde(default = "default_role_str")]
pub role: String,
}
fn default_role_str() -> String {
"read".to_string()
}
fn default_ttl() -> u64 {
30
}
#[derive(Serialize)]
pub struct CreateTokenResponse {
pub token: String,
pub expires_in_days: u64,
}
#[derive(Serialize)]
pub struct TokenListItem {
pub hash_prefix: String,
pub created_at: u64,
pub expires_at: u64,
pub last_used: Option<u64>,
pub description: Option<String>,
pub role: String,
}
#[derive(Serialize)]
pub struct TokenListResponse {
pub tokens: Vec<TokenListItem>,
}
/// Create a new API token (requires Basic auth)
async fn create_token(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTokenRequest>,
) -> Response {
// Verify user credentials first
let auth = match &state.auth {
Some(auth) => auth,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
};
if !auth.authenticate(&req.username, &req.password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let token_store = match &state.tokens {
Some(ts) => ts,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token storage not configured",
)
.into_response()
}
};
let role = match req.role.as_str() {
"read" => Role::Read,
"write" => Role::Write,
"admin" => Role::Admin,
_ => return (StatusCode::BAD_REQUEST, "Invalid role. Use: read, write, admin").into_response(),
};
match token_store.create_token(&req.username, req.ttl_days, req.description, role) {
Ok(token) => Json(CreateTokenResponse {
token,
expires_in_days: req.ttl_days,
})
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// List tokens for authenticated user
async fn list_tokens(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTokenRequest>,
) -> Response {
let auth = match &state.auth {
Some(auth) => auth,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
};
if !auth.authenticate(&req.username, &req.password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let token_store = match &state.tokens {
Some(ts) => ts,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token storage not configured",
)
.into_response()
}
};
let tokens: Vec<TokenListItem> = token_store
.list_tokens(&req.username)
.into_iter()
.map(|t| TokenListItem {
hash_prefix: t.token_hash[..16].to_string(),
created_at: t.created_at,
expires_at: t.expires_at,
last_used: t.last_used,
description: t.description,
role: t.role.to_string(),
})
.collect();
Json(TokenListResponse { tokens }).into_response()
}
#[derive(Deserialize)]
pub struct RevokeRequest {
pub username: String,
pub password: String,
pub hash_prefix: String,
}
/// Revoke a token
async fn revoke_token(
State(state): State<Arc<AppState>>,
Json(req): Json<RevokeRequest>,
) -> Response {
let auth = match &state.auth {
Some(auth) => auth,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
};
if !auth.authenticate(&req.username, &req.password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let token_store = match &state.tokens {
Some(ts) => ts,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token storage not configured",
)
.into_response()
}
};
match token_store.revoke_token(&req.hash_prefix) {
Ok(()) => (StatusCode::OK, "Token revoked").into_response(),
Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(),
}
}
/// Token management routes
pub fn token_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/tokens", post(create_token))
.route("/api/tokens/list", post(list_tokens))
.route("/api/tokens/revoke", post(revoke_token))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_htpasswd(entries: &[(&str, &str)]) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
for (username, password) in entries {
let hash = bcrypt::hash(password, 4).unwrap(); // cost=4 for speed in tests
writeln!(file, "{}:{}", username, hash).unwrap();
}
file.flush().unwrap();
file
}
#[test]
fn test_htpasswd_loading() {
let file = create_test_htpasswd(&[("admin", "secret"), ("user", "password")]);
let auth = HtpasswdAuth::from_file(file.path()).unwrap();
let users = auth.list_users();
assert_eq!(users.len(), 2);
assert!(users.contains(&"admin"));
assert!(users.contains(&"user"));
}
#[test]
fn test_htpasswd_loading_empty_file() {
let file = NamedTempFile::new().unwrap();
let auth = HtpasswdAuth::from_file(file.path());
assert!(auth.is_none());
}
#[test]
fn test_htpasswd_loading_with_comments() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# This is a comment").unwrap();
writeln!(file, "").unwrap();
let hash = bcrypt::hash("secret", 4).unwrap();
writeln!(file, "admin:{}", hash).unwrap();
file.flush().unwrap();
let auth = HtpasswdAuth::from_file(file.path()).unwrap();
assert_eq!(auth.list_users().len(), 1);
}
#[test]
fn test_authenticate_valid() {
let file = create_test_htpasswd(&[("test", "secret")]);
let auth = HtpasswdAuth::from_file(file.path()).unwrap();
assert!(auth.authenticate("test", "secret"));
}
#[test]
fn test_authenticate_invalid_password() {
let file = create_test_htpasswd(&[("test", "secret")]);
let auth = HtpasswdAuth::from_file(file.path()).unwrap();
assert!(!auth.authenticate("test", "wrong"));
}
#[test]
fn test_authenticate_unknown_user() {
let file = create_test_htpasswd(&[("test", "secret")]);
let auth = HtpasswdAuth::from_file(file.path()).unwrap();
assert!(!auth.authenticate("unknown", "secret"));
}
#[test]
fn test_is_public_path() {
// Public paths
assert!(is_public_path("/"));
assert!(is_public_path("/health"));
assert!(is_public_path("/ready"));
assert!(is_public_path("/metrics"));
assert!(is_public_path("/v2/"));
assert!(is_public_path("/v2"));
assert!(is_public_path("/ui"));
assert!(is_public_path("/ui/dashboard"));
assert!(is_public_path("/api-docs"));
assert!(is_public_path("/api-docs/openapi.json"));
assert!(is_public_path("/api/ui/stats"));
assert!(is_public_path("/api/tokens"));
assert!(is_public_path("/api/tokens/list"));
assert!(is_public_path("/api/tokens/revoke"));
// Protected paths
assert!(!is_public_path("/api/tokens/unknown"));
assert!(!is_public_path("/api/tokens/admin"));
assert!(!is_public_path("/api/tokens/extra/path"));
assert!(!is_public_path("/v2/myimage/blobs/sha256:abc"));
assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
assert!(!is_public_path(
"/maven2/com/example/artifact/1.0/artifact.jar"
));
assert!(!is_public_path("/npm/lodash"));
}
#[test]
fn test_hash_password() {
let hash = hash_password("test123").unwrap();
assert!(hash.starts_with("$2"));
assert!(bcrypt::verify("test123", &hash).unwrap());
}
}