Redo auth once again to add a proper 'auth gate' middleware

This commit is contained in:
SeanOMik 2023-07-14 00:37:40 -04:00
parent 2ecebab330
commit 618c04c29b
Signed by: SeanOMik
GPG Key ID: 568F326C7EB33ACB
11 changed files with 130 additions and 144 deletions

View File

@ -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)
```

View File

@ -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<AuthBasic>, state: State<Arc<AppS
debug!("Authentication failed, incorrect password!");
// TODO: Dont unwrap, find a way to return multiple scopes
return Ok(unauthenticated_response(&state.config, auth.scope.first().unwrap()));
return Ok(auth_challenge_response(&state.config, Some(auth.scope.first().unwrap().clone())));
}
drop(auth_driver);

View File

@ -1,6 +1,5 @@
use std::sync::Arc;
use axum::Extension;
use axum::body::StreamBody;
use axum::extract::{State, Path};
use axum::http::{StatusCode, header, HeaderName};
@ -8,28 +7,9 @@ use axum::response::{IntoResponse, Response};
use tokio_util::io::ReaderStream;
use crate::app_state::AppState;
use crate::auth::{access_denied_response, unauthenticated_response};
use crate::database::Database;
use crate::dto::RepositoryVisibility;
use crate::dto::scope::{Scope, ScopeType, Action};
use crate::dto::user::{Permission, UserAuth};
use crate::error::AppError;
pub async fn digest_exists_head(Path((name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>, auth: Option<UserAuth>) -> Result<Response, AppError> {
// 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<Arc<AppState>>) -> Result<Response, AppError> {
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<Arc<AppState>>, auth: Option<UserAuth>) -> Result<Response, AppError> {
// 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<Arc<AppState>>) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
if let Some(len) = storage.digest_length(&layer_digest).await? {

View File

@ -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<Arc<AppState>>, auth: UserAuth, body: String) -> Result<Response, AppError> {
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);

View File

@ -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<Arc<AppState>>) -> 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()
}

View File

@ -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<UserAuth>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
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<Response, AppError> {
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<Arc<AppState>>, mut body: BodyStream) -> Result<Response, AppError> {
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<Arc<AppState>>, mut body: BodyStream) -> Result<Response, AppError> {
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<HashMap<String, String>>, auth: UserAuth, state: State<Arc<AppState>>, body: Bytes) -> Result<Response, AppError> {
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<HashMap<String, String>>, state: State<Arc<AppState>>, body: Bytes) -> Result<Response, AppError> {
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<Arc<AppState>>, auth: UserAuth) -> Result<Response, AppError> {
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<Arc<AppState>>) -> Result<Response, AppError> {
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<Arc<AppState>>, auth: UserAuth) -> Result<Response, AppError> {
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<Arc<AppState>>) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
let ending = storage.digest_length(&layer_uuid).await?.unwrap_or(0);

View File

@ -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<RepositoryVisibility>) -> anyhow::Result<bool> {
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<bool> {
@ -133,9 +138,13 @@ pub async fn require_auth<B>(State(state): State<Arc<AppState>>, 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<Scope>) -> 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<B>(State(state): State<Arc<AppState>>, auth: Option<UserAuth>, request: Request<B>, next: Next<B>) -> Result<Response, Rejection> {
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)
}

View File

@ -550,7 +550,7 @@ impl Database for Pool<Sqlite> {
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);

View File

@ -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,

View File

@ -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<Arc<AppState>> 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<Arc<AppState>> 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<Arc<AppState>> 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;

View File

@ -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());