From 618c04c29b8fc448f0d4c034a7beac8762a44f38 Mon Sep 17 00:00:00 2001 From: SeanOMik Date: Fri, 14 Jul 2023 00:37:40 -0400 Subject: [PATCH] Redo auth once again to add a proper 'auth gate' middleware --- README.md | 5 +- src/api/auth.rs | 4 +- src/api/blobs.rs | 37 +------------- src/api/manifests.rs | 7 --- src/api/mod.rs | 7 +-- src/api/uploads.rs | 68 ++++++------------------- src/auth/mod.rs | 116 +++++++++++++++++++++++++++++++++++-------- src/database/mod.rs | 2 +- src/dto/scope.rs | 4 +- src/dto/user.rs | 18 +++---- src/main.rs | 6 +-- 11 files changed, 130 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index 7ab2a5b..6adf0a8 100644 --- a/README.md +++ b/README.md @@ -24,17 +24,20 @@ $ htpasswd -nB ``` 3. Insert the new user's email, password hash into the `user_logins` table. The salt is not used, so you can put whatever there +> WARNING: Ensure that the username is all lowercase!!! ```sql INSERT INTO user_logins (email, password_hash, password_salt) VALUES ("example@email.com", "some password", "random salt") ``` 4. Insert the new user into another table, `users` so the registry knows the source of the user +> WARNING: Ensure that the username is all lowercase!!! ```sql INSERT INTO users (username, email, login_source) VALUES ("example", "example@email.com", 0) ``` a `login_source` of `0` means database -5. Give the user registry permissions +1. Give the user registry permissions +> WARNING: Ensure that the username is all lowercase!!! ```sql INSERT INTO user_registry_permissions (email, user_type) VALUES ("example@email.com", 1) ``` diff --git a/src/api/auth.rs b/src/api/auth.rs index 20fa089..8396540 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -15,7 +15,7 @@ use rand::Rng; use crate::{dto::{scope::Scope, user::TokenInfo}, app_state::AppState}; use crate::database::Database; -use crate::auth::unauthenticated_response; +use crate::auth::auth_challenge_response; #[derive(Deserialize, Debug)] pub struct TokenAuthRequest { @@ -180,7 +180,7 @@ pub async fn auth_basic_get(basic_auth: Option, state: State, state: State>, auth: Option) -> Result { - // Check if the user has permission to pull, or that the repository is public - if let Some(auth) = auth { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? { - return Ok(access_denied_response(&state.config)); - } - } else { - let database = &state.database; - if database.get_repository_visibility(&name).await? != Some(RepositoryVisibility::Public) { - let s = Scope::new(ScopeType::Repository, name, &[Action::Push, Action::Pull]); - return Ok(unauthenticated_response(&state.config, &s)); - } - } - +pub async fn digest_exists_head(Path((_name, layer_digest)): Path<(String, String)>, state: State>) -> Result { let storage = state.storage.lock().await; if storage.has_digest(&layer_digest).await? { @@ -47,20 +27,7 @@ pub async fn digest_exists_head(Path((name, layer_digest)): Path<(String, String Ok(StatusCode::NOT_FOUND.into_response()) } -pub async fn pull_digest_get(Path((name, layer_digest)): Path<(String, String)>, state: State>, auth: Option) -> Result { - // Check if the user has permission to pull, or that the repository is public - if let Some(auth) = auth { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? { - return Ok(access_denied_response(&state.config)); - } - } else { - let database = &state.database; - if database.get_repository_visibility(&name).await? != Some(RepositoryVisibility::Public) { - return Ok(access_denied_response(&state.config)); - } - } - +pub async fn pull_digest_get(Path((_name, layer_digest)): Path<(String, String)>, state: State>) -> Result { let storage = state.storage.lock().await; if let Some(len) = storage.digest_length(&layer_digest).await? { diff --git a/src/api/manifests.rs b/src/api/manifests.rs index c974fd7..f553029 100644 --- a/src/api/manifests.rs +++ b/src/api/manifests.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use axum::Extension; use axum::extract::{Path, State}; use axum::response::{Response, IntoResponse}; use axum::http::{StatusCode, HeaderName, header}; @@ -17,12 +16,6 @@ use crate::dto::user::{UserAuth, Permission}; use crate::error::AppError; pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State>, auth: UserAuth, body: String) -> Result { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? { - return Ok(access_denied_response(&state.config)); - } - drop(auth_driver); - // Calculate the sha256 digest for the manifest. let calculated_hash = sha256::digest(body.clone()); let calculated_digest = format!("sha256:{}", calculated_hash); diff --git a/src/api/mod.rs b/src/api/mod.rs index bdd2def..e7e0113 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,8 +2,7 @@ use std::sync::Arc; use axum::extract::State; use axum::response::{IntoResponse, Response}; -use axum::http::{StatusCode, HeaderName, HeaderMap, header}; -use tracing::debug; +use axum::http::{StatusCode, HeaderName}; use crate::app_state::AppState; @@ -14,17 +13,13 @@ pub mod tags; pub mod catalog; pub mod auth; -use crate::dto::user::UserAuth; - /// https://docs.docker.com/registry/spec/api/#api-version-check /// full endpoint: `/v2/` pub async fn version_check(_state: State>) -> Response { - let bearer = format!("Bearer realm=\"{}/auth\"", _state.config.url()); ( StatusCode::UNAUTHORIZED, [ ( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0" ), - //( header::WWW_AUTHENTICATE, &bearer ), ] ).into_response() } \ No newline at end of file diff --git a/src/api/uploads.rs b/src/api/uploads.rs index aa9eae1..4c7a558 100644 --- a/src/api/uploads.rs +++ b/src/api/uploads.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::sync::Arc; -use axum::Extension; use axum::http::{StatusCode, header, HeaderName}; use axum::extract::{Path, BodyStream, State, Query}; use axum::response::{IntoResponse, Response}; @@ -12,47 +11,26 @@ use futures::StreamExt; use tracing::{debug, warn}; use crate::app_state::AppState; -use crate::auth::{access_denied_response, unauthenticated_response}; use crate::byte_stream::ByteStream; -use crate::dto::scope::{Scope, ScopeType, Action}; -use crate::dto::user::{UserAuth, Permission}; use crate::error::AppError; /// Starting an upload -pub async fn start_upload_post(Path((name, )): Path<(String, )>, auth: Option, state: State>) -> Result { - if auth.is_none() { - debug!("atuh was not given, responding with scope"); - let s = Scope::new(ScopeType::Repository, name, &[Action::Push, Action::Pull]); - return Ok(unauthenticated_response(&state.config, &s)); - } - let auth = auth.unwrap(); +pub async fn start_upload_post(Path((name, )): Path<(String, )>) -> Result { + debug!("Upload requested"); + let uuid = uuid::Uuid::new_v4(); - let mut auth_driver = state.auth_checker.lock().await; - if auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? { - debug!("Upload requested"); - let uuid = uuid::Uuid::new_v4(); + debug!("Requesting upload of image {}, generated uuid: {}", name, uuid); - debug!("Requesting upload of image {}, generated uuid: {}", name, uuid); + let location = format!("/v2/{}/blobs/uploads/{}", name, uuid.to_string()); + debug!("Constructed upload url: {}", location); - let location = format!("/v2/{}/blobs/uploads/{}", name, uuid.to_string()); - debug!("Constructed upload url: {}", location); - - return Ok(( - StatusCode::ACCEPTED, - [ (header::LOCATION, location) ] - ).into_response()); - } - - Ok(access_denied_response(&state.config)) + return Ok(( + StatusCode::ACCEPTED, + [ (header::LOCATION, location) ] + ).into_response()); } -pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, auth: UserAuth, state: State>, mut body: BodyStream) -> Result { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? { - return Ok(access_denied_response(&state.config)); - } - drop(auth_driver); - +pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, state: State>, mut body: BodyStream) -> Result { let storage = state.storage.lock().await; let current_size = storage.digest_length(&layer_uuid).await?; @@ -105,13 +83,7 @@ pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, ).into_response()) } -pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query>, auth: UserAuth, state: State>, body: Bytes) -> Result { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? { - return Ok(access_denied_response(&state.config)); - } - drop(auth_driver); - +pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query>, state: State>, body: Bytes) -> Result { let digest = query.get("digest").unwrap(); let storage = state.storage.lock().await; @@ -134,13 +106,7 @@ pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, S ).into_response()) } -pub async fn cancel_upload_delete(Path((name, layer_uuid)): Path<(String, String)>, state: State>, auth: UserAuth) -> Result { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? { - return Ok(access_denied_response(&state.config)); - } - drop(auth_driver); - +pub async fn cancel_upload_delete(Path((_name, layer_uuid)): Path<(String, String)>, state: State>) -> Result { let storage = state.storage.lock().await; storage.delete_digest(&layer_uuid).await?; @@ -148,13 +114,7 @@ pub async fn cancel_upload_delete(Path((name, layer_uuid)): Path<(String, String Ok(StatusCode::OK.into_response()) } -pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State>, auth: UserAuth) -> Result { - let mut auth_driver = state.auth_checker.lock().await; - if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? { - return Ok(access_denied_response(&state.config)); - } - drop(auth_driver); - +pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State>) -> Result { let storage = state.storage.lock().await; let ending = storage.digest_length(&layer_uuid).await?.unwrap_or(0); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 123253c..df74c65 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -2,12 +2,11 @@ pub mod ldap_driver; use std::{ops::Deref, sync::Arc}; -use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request}, middleware::Next, response::{Response, IntoResponse}}; +use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request, Method}, middleware::Next, response::{Response, IntoResponse}}; -use sqlx::{Pool, Sqlite}; -use tracing::debug; +use tracing::{debug, warn, error}; -use crate::{app_state::AppState, dto::{user::{Permission, RegistryUserType}, RepositoryVisibility, scope::{self, Scope}}, config::Config}; +use crate::{app_state::AppState, dto::{user::{Permission, RegistryUserType, UserAuth}, RepositoryVisibility, scope::{Scope, ScopeType, Action}}, config::Config}; use crate::database::Database; use async_trait::async_trait; @@ -31,23 +30,29 @@ where T: Database + Send + Sync { async fn user_has_permission(&mut self, email: String, repository: String, permission: Permission, required_visibility: Option) -> anyhow::Result { - let allowed_to: bool = { - if self.get_repository_owner(&repository).await? - .map_or(false, |owner| owner == email) { - - debug!("Allowing request, user is owner of repository"); - true - } else { + match self.get_repository_owner(&repository).await? { + Some(owner) if owner == email => return Ok(true), + Some(_other_owner) => { match self.get_user_registry_type(email.clone()).await? { - Some(RegistryUserType::Admin) => true, + Some(RegistryUserType::Admin) => return Ok(true), _ => { - check_user_permissions(self, email, repository, permission, required_visibility).await? + return Ok(check_user_permissions(self, email, repository, permission, required_visibility).await?); } } - } - }; - - Ok(allowed_to) + }, + None => { + // If the repository does not exist, see if its the per-user repositories and autocreate it. + if let Some(user) = self.get_user(email.clone()).await? { + let username = user.username.to_lowercase(); + if repository.starts_with(&username) { + self.save_repository(&repository, RepositoryVisibility::Private, Some(email), None).await?; + return Ok(true); + } + } + }, + } + + Ok(false) } async fn verify_user_login(&mut self, email: String, password: String) -> anyhow::Result { @@ -133,9 +138,13 @@ pub async fn require_auth(State(state): State>, mut request: Re /// Creates a response with an Unauthorized (401) status code. /// The www-authenticate header is set to notify the client of where to authorize with. #[inline(always)] -pub fn unauthenticated_response(config: &Config, scope: &Scope) -> Response { - let bearer = format!("Bearer realm=\"{}/auth\",service=\"{}\",scope=\"{}\"", config.url(), "localhost:3000", scope); +pub fn auth_challenge_response(config: &Config, scope: Option) -> Response { + let bearer = match scope { + Some(scope) => format!("Bearer realm=\"{}/auth\",scope=\"{}\"", config.url(), scope), + None => format!("Bearer realm=\"{}/auth\"", config.url()) + }; debug!("responding with www-authenticate header of: \"{}\"", bearer); + ( StatusCode::UNAUTHORIZED, [ @@ -143,18 +152,83 @@ pub fn unauthenticated_response(config: &Config, scope: &Scope) -> Response { ( header::CONTENT_TYPE, "application/json".to_string() ), ( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() ) ], - "{\"errors\":[{\"code\":\"UNAUTHORIZED\",\"message\":\"access to the requested resource is not authorized\",\"detail\":[{\"Type\":\"repository\",\"Name\":\"samalba/my-app\",\"Action\":\"pull\"},{\"Type\":\"repository\",\"Name\":\"samalba/my-app\",\"Action\":\"push\"}]}]}" + //"{\"errors\":[{\"code\":\"UNAUTHORIZED\",\"message\":\"access to the requested resource is not authorized\",\"detail\":[{\"Type\":\"repository\",\"Name\":\"samalba/my-app\",\"Action\":\"pull\"},{\"Type\":\"repository\",\"Name\":\"samalba/my-app\",\"Action\":\"push\"}]}]}" ).into_response() } /// Creates a response with a Forbidden (403) status code. /// No other headers are set. #[inline(always)] -pub fn access_denied_response(config: &Config) -> Response { +pub fn access_denied_response(_config: &Config) -> Response { ( StatusCode::FORBIDDEN, [ ( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() ) ] ).into_response() +} + +pub async fn check_auth(State(state): State>, auth: Option, request: Request, next: Next) -> Result { + let config = &state.config; + // note: url is relative to /v2 + let url = request.uri().to_string(); + + if url == "/" && auth.is_none() { + debug!("Responding to /v2/ with an auth challenge"); + return Ok(auth_challenge_response(config, None)); + } + + let url_split: Vec<&str> = url.split("/").skip(1).collect(); + let target_name = url_split[0].replace("%2F", "/"); + let target_type = url_split[1]; + + // check if the request is targeting something inside an image repository + if target_type == "blobs" || target_type == "uploads" || target_type == "manifests" { + let scope_actions: &[Action] = match request.method().clone() { + Method::GET | Method::HEAD => &[Action::Pull], + Method::POST | Method::PATCH | Method::PUT => &[Action::Pull, Action::Push], + _ => &[], + }; + let scope = Scope::new(ScopeType::Repository, target_name.clone(), scope_actions); + + // respond with an auth challenge if there is no auth header. + //if !headers.contains_key(header::AUTHORIZATION) && auth.is_none() { + if auth.is_none() { + debug!("User is not authenticated, sending challenge"); + return Ok(auth_challenge_response(config, Some(scope))); + } + let auth = auth.unwrap(); + + let mut auth_checker = state.auth_checker.lock().await; + + // Check permission for each action + for action in scope_actions { + // action to permission + let permission = match action { + Action::Pull => Permission::PULL, + Action::Push => Permission::PUSH, + _ => Permission::NONE, + }; + + // get optional required visibility from action + let vis = match action { + Action::Pull => Some(RepositoryVisibility::Public), + _ => None, + }; + + match auth_checker.user_has_permission(auth.user.email.clone(), target_name.clone(), permission, vis).await { + Ok(false) => return Ok(auth_challenge_response(config, Some(scope))), + Ok(true) => { }, + Err(e) => { + error!("Error when checking user permissions! {}", e); + + return Err((StatusCode::INTERNAL_SERVER_ERROR, HeaderMap::new())); + }, + } + } + } else { + warn!("Unhandled auth check for '{target_type}'!!"); // TODO + } + + Ok(next.run(request).await) } \ No newline at end of file diff --git a/src/database/mod.rs b/src/database/mod.rs index 58748d1..76de956 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -550,7 +550,7 @@ impl Database for Pool { let (expiry, created_at) = (Utc.timestamp_millis_opt(expiry).single(), Utc.timestamp_millis_opt(created_at).single()); if let (Some(expiry), Some(created_at)) = (expiry, created_at) { - let user = User::new(email, user_row.0, LoginSource::try_from(user_row.1)?); + let user = User::new(user_row.0, email, LoginSource::try_from(user_row.1)?); let token = TokenInfo::new(token, expiry, created_at); let auth = UserAuth::new(user, token); diff --git a/src/dto/scope.rs b/src/dto/scope.rs index f7b11b9..fd2f70e 100644 --- a/src/dto/scope.rs +++ b/src/dto/scope.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, de::Visitor}; use std::fmt; -#[derive(Default, Debug)] +#[derive(Default, Debug, Clone)] pub enum ScopeType { #[default] Unknown, @@ -37,7 +37,7 @@ impl fmt::Display for Action { } } -#[derive(Default, Debug)] +#[derive(Default, Debug, Clone)] pub struct Scope { scope_type: ScopeType, path: String, diff --git a/src/dto/user.rs b/src/dto/user.rs index 6953c72..0c2bb6b 100644 --- a/src/dto/user.rs +++ b/src/dto/user.rs @@ -1,10 +1,10 @@ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; -use axum::{http::{StatusCode, header, HeaderName, HeaderMap, Request, request::Parts}, extract::{FromRequest, FromRequestParts}}; +use axum::{http::{StatusCode, header, HeaderName, HeaderMap, request::Parts}, extract::FromRequestParts}; use bitflags::bitflags; use chrono::{DateTime, Utc}; -use tracing::{debug, warn}; +use tracing::debug; use crate::{app_state::AppState, database::Database}; @@ -92,16 +92,9 @@ impl FromRequestParts> for UserAuth { let auth = String::from( parts.headers .get(header::AUTHORIZATION) - .ok_or( - { - debug!("Client did not send authorization header"); - (StatusCode::UNAUTHORIZED, failure_headers.clone()) - })? + .ok_or((StatusCode::UNAUTHORIZED, failure_headers.clone()))? .to_str() - .map_err(|_| { - warn!("Failure to convert Authorization header to string!"); - (StatusCode::UNAUTHORIZED, failure_headers.clone()) - })? + .map_err(|_| (StatusCode::UNAUTHORIZED, failure_headers.clone()))? ); debug!("got auth header"); @@ -118,7 +111,7 @@ impl FromRequestParts> for UserAuth { // If the token is not valid, return an unauthorized response let database = &state.database; if let Ok(Some(user)) = database.verify_user_token(token.to_string()).await { - debug!("Authenticated user through middleware: {}", user.user.username); + debug!("Authenticated user through request extractor: {}", user.user.username); Ok(user) } else { @@ -135,6 +128,7 @@ impl FromRequestParts> for UserAuth { bitflags! { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Permission: u32 { + const NONE = 0b0000; const PULL = 0b0001; const PUSH = 0b0010; const EDIT = 0b0111; diff --git a/src/main.rs b/src/main.rs index f9a976d..0b59da8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,14 +22,13 @@ use axum::ServiceExt; use axum_server::tls_rustls::RustlsConfig; use lazy_static::lazy_static; use regex::Regex; -use sqlx::ConnectOptions; use tokio::fs::File; use tower_layer::Layer; use sqlx::sqlite::{SqlitePoolOptions, SqliteConnectOptions, SqliteJournalMode}; use tokio::sync::Mutex; use tower_http::normalize_path::NormalizePathLayer; -use tracing::{debug, Level, info}; +use tracing::{debug, info}; use app_state::AppState; use database::Database; @@ -117,6 +116,7 @@ async fn main() -> anyhow::Result<()> { let state = Arc::new(AppState::new(pool, storage_driver, config, auth_driver)); //let auth_middleware = axum::middleware::from_fn_with_state(state.clone(), auth::require_auth); + let auth_middleware = axum::middleware::from_fn_with_state(state.clone(), auth::check_auth); let path_middleware = axum::middleware::from_fn(change_request_paths); let app = Router::new() @@ -143,7 +143,7 @@ async fn main() -> anyhow::Result<()> { .put(api::manifests::upload_manifest_put) .head(api::manifests::manifest_exists_head) .delete(api::manifests::delete_manifest)) - //.layer(auth_middleware) // require auth for ALL v2 routes + .layer(auth_middleware) // require auth for ALL v2 routes ) .with_state(state) .layer(TraceLayer::new_for_http());