mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 09:10:32 +00:00
When auth is enabled with anonymous_read=true, GET/HEAD requests are allowed without credentials (pull/download), while write operations (PUT/POST/DELETE/PATCH) still require authentication. Use case: public demo registries, read-only mirrors. Config: NORA_AUTH_ANONYMOUS_READ=true or auth.anonymous_read=true
476 lines
14 KiB
Rust
476 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::tokens::Role;
|
|
use crate::AppState;
|
|
|
|
/// 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;
|
|
}
|
|
|
|
// Allow anonymous read if configured
|
|
let is_read_method = matches!(
|
|
*request.method(),
|
|
axum::http::Method::GET | axum::http::Method::HEAD
|
|
);
|
|
if state.config.auth.anonymous_read && is_read_method {
|
|
// Read requests allowed without auth
|
|
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());
|
|
}
|
|
}
|