Implement user auth for pushing and pulling images

This commit is contained in:
SeanOMik 2023-05-06 19:24:03 -04:00
parent 5f74e46607
commit e3a0554823
Signed by: SeanOMik
GPG Key ID: 568F326C7EB33ACB
17 changed files with 555 additions and 98 deletions

17
Cargo.lock generated
View File

@ -119,7 +119,7 @@ checksum = "113713495a32dd0ab52baf5c10044725aa3aec00b31beda84218e469029b72a3"
dependencies = [
"async-trait",
"axum-core",
"bitflags",
"bitflags 1.3.2",
"bytes",
"futures-util",
"http",
@ -215,6 +215,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813"
[[package]]
name = "block-buffer"
version = "0.9.0"
@ -311,7 +317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca505fd2c00136e0d0cd34bcd8b6bd0b59d5779aab396054b716334230c1c"
dependencies = [
"atty",
"bitflags",
"bitflags 1.3.2",
"clap_derive",
"clap_lex",
"once_cell",
@ -1077,6 +1083,7 @@ dependencies = [
"axum-auth",
"axum-macros",
"bcrypt",
"bitflags 2.2.1",
"bytes",
"chrono",
"clap",
@ -1329,7 +1336,7 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags",
"bitflags 1.3.2",
]
[[package]]
@ -1594,7 +1601,7 @@ checksum = "fa8241483a83a3f33aa5fff7e7d9def398ff9990b2752b6c6112b83c6d246029"
dependencies = [
"ahash",
"atoi",
"bitflags",
"bitflags 1.3.2",
"byteorder",
"bytes",
"crc",
@ -1892,7 +1899,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658"
dependencies = [
"bitflags",
"bitflags 1.3.2",
"bytes",
"futures-core",
"futures-util",

View File

@ -50,3 +50,4 @@ hmac = "0.12.1"
sha2 = "0.10.6"
rand = "0.8.5"
bcrypt = "0.14.0"
bitflags = "2.2.1"

23
docs/database.md Normal file
View File

@ -0,0 +1,23 @@
## user_permissions table
The field `repository_custom_scope` is an integer created by using bitwise operations.
* pull - `0b0001`
* push - `0b0010`
* edit - `0b0111`
* admin - `0b1111`
### Predefined user permission scopes:
* limited
* pull image
* developer
* pull and push image
* master
* retag images
* project_admin
* configure repository access
## user_registry_permissions
user_type:
* regular user = 0
* admin = 1

View File

@ -2,7 +2,7 @@ use std::{sync::Arc, collections::{HashMap, BTreeMap}, time::{SystemTime, UNIX_E
use axum::{extract::{Query, State}, response::{IntoResponse, Response}, http::{StatusCode, header}, Form};
use axum_auth::AuthBasic;
use chrono::{DateTime, Utc};
use chrono::{DateTime, Utc, Duration};
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, span, Level};
@ -12,7 +12,7 @@ use sha2::Sha256;
use rand::Rng;
use crate::{dto::scope::Scope, app_state::AppState};
use crate::{dto::{scope::Scope, user::{UserAuth, TokenInfo}}, app_state::AppState};
use crate::database::Database;
#[derive(Deserialize, Debug)]
@ -41,13 +41,12 @@ pub struct AuthResponse {
issued_at: String,
}
fn create_jwt_token(account: String) -> anyhow::Result<String> {
/// In the returned UserToken::user, only the username is specified
fn create_jwt_token(account: &str) -> anyhow::Result<TokenInfo> {
let key: Hmac<Sha256> = Hmac::new_from_slice(b"some-secret")?;
let now = SystemTime::now();
let now_secs = now
.duration_since(UNIX_EPOCH)?
.as_secs();
let now = chrono::offset::Utc::now();
let now_secs = now.timestamp();
// Construct the claims for the token
let mut claims = BTreeMap::new();
@ -55,19 +54,23 @@ fn create_jwt_token(account: String) -> anyhow::Result<String> {
claims.insert("subject", &account);
//claims.insert("audience", auth.service);
let notbefore = format!("{}", now_secs - 10);
let issuedat = format!("{}", now_secs);
let not_before = format!("{}", now_secs - 10);
let issued_at = format!("{}", now_secs);
let expiration = format!("{}", now_secs + 20);
claims.insert("notbefore", &notbefore);
claims.insert("issuedat", &issuedat);
claims.insert("notbefore", &not_before);
claims.insert("issuedat", &issued_at);
claims.insert("expiration", &expiration); // TODO: 20 seconds expiry for testing
let issued_at = now;
let expiration = now + Duration::seconds(20);
// Create a randomized jwtid
let mut rng = rand::thread_rng();
let jwtid = format!("{}", rng.gen::<u64>());
claims.insert("jwtid", &jwtid);
Ok(claims.sign_with_key(&key)?)
let token_str = claims.sign_with_key(&key)?;
Ok(TokenInfo::new(token_str, expiration, issued_at))
}
pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppState>>, Query(params): Query<HashMap<String, String>>, form: Option<Form<AuthForm>>) -> Response {
@ -142,6 +145,7 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
// Process all the scopes
if let Some(scope) = params.get("scope") {
// TODO: Handle multiple scopes
auth.scope.push(Scope::try_from(&scope[..]).unwrap());
}
@ -159,7 +163,7 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
debug!("Constructed auth request");
if let (Some(account), Some(password)) = (auth.account, auth.password) {
if let (Some(account), Some(password)) = (&auth.account, auth.password) {
// Ensure that the password is correct
let database = &state.database;
if !database.verify_user_login(account.clone(), password).await.unwrap() {
@ -168,11 +172,11 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
StatusCode::UNAUTHORIZED
).into_response();
}
drop(database);
debug!("User password is correct");
let now = SystemTime::now();
let token_str = create_jwt_token(account).unwrap();
let token = create_jwt_token(account).unwrap();
let token_str = token.token;
debug!("Created jwt token");
@ -189,8 +193,8 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
let json_str = serde_json::to_string(&auth_response).unwrap();
let mut auth_storage = state.auth_storage.lock().await;
auth_storage.valid_tokens.insert(token_str.clone());
database.store_user_token(token_str.clone(), account.clone(), token.expiry, token.created_at).await.unwrap();
drop(database);
return (
StatusCode::OK,

View File

@ -1,5 +1,6 @@
use std::sync::Arc;
use axum::Extension;
use axum::body::StreamBody;
use axum::extract::{State, Path};
use axum::http::{StatusCode, header, HeaderName};
@ -7,8 +8,23 @@ use axum::response::{IntoResponse, Response};
use tokio_util::io::ReaderStream;
use crate::app_state::AppState;
use crate::auth_storage::{does_user_have_permission, get_unauthenticated_response};
use crate::database::Database;
use crate::dto::RepositoryVisibility;
use crate::dto::user::{Permission, RegistryUserType, UserAuth};
pub async fn digest_exists_head(Path((name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
// Check if the user has permission to pull, or that the repository is public
let database = &state.database;
if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap()
&& !database.get_repository_visibility(&name).await.unwrap()
.and_then(|v| Some(v == RepositoryVisibility::Public))
.unwrap_or_else(|| false) {
return get_unauthenticated_response(&state.config);
}
drop(database);
pub async fn digest_exists_head(Path((_name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>) -> Response {
let storage = state.storage.lock().await;
if storage.has_digest(&layer_digest).await.unwrap() {
@ -26,7 +42,18 @@ pub async fn digest_exists_head(Path((_name, layer_digest)): Path<(String, Strin
StatusCode::NOT_FOUND.into_response()
}
pub async fn pull_digest_get(Path((_name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>) -> Response {
pub async fn pull_digest_get(Path((name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
// Check if the user has permission to pull, or that the repository is public
let database = &state.database;
if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap()
&& !database.get_repository_visibility(&name).await.unwrap()
.and_then(|v| Some(v == RepositoryVisibility::Public))
.unwrap_or_else(|| false) {
return get_unauthenticated_response(&state.config);
}
drop(database);
let storage = state.storage.lock().await;
if let Some(len) = storage.digest_length(&layer_digest).await.unwrap() {

View File

@ -36,7 +36,7 @@ pub async fn list_repositories(Query(params): Query<ListRepositoriesParams>, sta
let last_repo = repos.last().and_then(|s| Some(s.clone()));
// Construct the link header
let url = &state.config.url;
let url = &state.config.get_url();
let mut url = format!("<{}/v2/_catalog?n={}", url, limit);
if let Some(last_repo) = last_repo {
url += &format!("&limit={}", last_repo);

View File

@ -1,26 +1,33 @@
use std::sync::Arc;
use axum::Extension;
use axum::extract::{Path, State};
use axum::response::{Response, IntoResponse};
use axum::http::{StatusCode, HeaderMap, HeaderName, header};
use tracing::log::warn;
use tracing::{debug, info};
use crate::auth_storage::{does_user_have_permission, get_unauthenticated_response};
use crate::app_state::AppState;
use crate::database::Database;
use crate::dto::RepositoryVisibility;
use crate::dto::digest::Digest;
use crate::dto::manifest::Manifest;
use crate::dto::user::{UserAuth, Permission};
pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>, body: String) -> Response {
if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
return get_unauthenticated_response(&state.config);
}
pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, body: String) -> Response {
// Calculate the sha256 digest for the manifest.
let calculated_hash = sha256::digest(body.clone());
let calculated_digest = format!("sha256:{}", calculated_hash);
let database = &state.database;
// Create the image repository and save the image manifest.
database.save_repository(&name).await.unwrap();
// Create the image repository and save the image manifest. This repository will be private by default
database.save_repository(&name, RepositoryVisibility::Private, None).await.unwrap();
database.save_manifest(&name, &calculated_digest, &body).await.unwrap();
// If the reference is not a digest, then it must be a tag name.
@ -54,12 +61,23 @@ pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>
}
}
pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>) -> Response {
pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
// Check if the user has permission to pull, or that the repository is public
let database = &state.database;
if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap()
&& !database.get_repository_visibility(&name).await.unwrap()
.and_then(|v| Some(v == RepositoryVisibility::Public))
.unwrap_or_else(|| false) {
return get_unauthenticated_response(&state.config);
}
drop(database);
let database = &state.database;
let digest = match Digest::is_digest(&reference) {
true => reference.clone(),
false => {
debug!("Attempting to get manifest digest using tag (name={}, reference={})", name, reference);
debug!("Attempting to get manifest digest using tag (repository={}, reference={})", name, reference);
if let Some(tag) = database.get_tag(&name, &reference).await.unwrap() {
tag.manifest_digest
} else {
@ -90,7 +108,18 @@ pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>,
).into_response()
}
pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>) -> Response {
pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
// Check if the user has permission to pull, or that the repository is public
let database = &state.database;
if !does_user_have_permission(database, auth.user.username, name.clone(), Permission::PULL).await.unwrap()
&& !database.get_repository_visibility(&name).await.unwrap()
.and_then(|v| Some(v == RepositoryVisibility::Public))
.unwrap_or_else(|| false) {
return get_unauthenticated_response(&state.config);
}
drop(database);
// Get the digest from the reference path.
let database = &state.database;
let digest = match Digest::is_digest(&reference) {
@ -124,7 +153,11 @@ pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)
).into_response()
}
pub async fn delete_manifest(Path((name, reference)): Path<(String, String)>, headers: HeaderMap, state: State<Arc<AppState>>) -> Response {
pub async fn delete_manifest(Path((name, reference)): Path<(String, String)>, headers: HeaderMap, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
return get_unauthenticated_response(&state.config);
}
let _authorization = headers.get("Authorization").unwrap(); // TODO: use authorization header
let database = &state.database;

View File

@ -15,10 +15,11 @@ pub mod catalog;
pub mod auth;
use crate::auth_storage::AuthToken;
use crate::dto::user::UserAuth;
/// https://docs.docker.com/registry/spec/api/#api-version-check
/// full endpoint: `/v2/`
pub async fn version_check(Extension(AuthToken(_token)): Extension<AuthToken>, _state: State<Arc<AppState>>) -> Response {
pub async fn version_check(Extension(_auth): Extension<UserAuth>, _state: State<Arc<AppState>>) -> Response {
(
StatusCode::OK,
[( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0" )]

View File

@ -37,7 +37,7 @@ pub async fn list_tags(Path((name, )): Path<(String, )>, Query(params): Query<Li
let last_tag = tags.last();
// Construct the link header
let url = &state.config.url;
let url = &state.config.get_url();
let mut url = format!("<{}/v2/{}/tags/list?n={}", url, name, limit);
if let Some(last_tag) = last_tag {
url += &format!("&limit={}", last_tag.name);

View File

@ -2,6 +2,7 @@ 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};
@ -11,25 +12,36 @@ use futures::StreamExt;
use tracing::{debug, warn};
use crate::app_state::AppState;
use crate::auth_storage::{does_user_have_permission, get_unauthenticated_response};
use crate::byte_stream::ByteStream;
use crate::database::Database;
use crate::dto::user::{UserAuth, Permission, RegistryUser, RegistryUserType};
/// Starting an upload
pub async fn start_upload_post(Path((name, )): Path<(String, )>) -> impl IntoResponse {
debug!("Upload starting");
let uuid = uuid::Uuid::new_v4();
pub async fn start_upload_post(Path((name, )): Path<(String, )>, Extension(auth): Extension<UserAuth>, state: State<Arc<AppState>>) -> Response {
if does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
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);
(
StatusCode::ACCEPTED,
[ (header::LOCATION, location) ]
)
return (
StatusCode::ACCEPTED,
[ (header::LOCATION, location) ]
).into_response();
}
get_unauthenticated_response(&state.config)
}
pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>, mut body: BodyStream) -> Response {
pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, Extension(auth): Extension<UserAuth>, state: State<Arc<AppState>>, mut body: BodyStream) -> Response {
if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
return get_unauthenticated_response(&state.config);
}
let storage = state.storage.lock().await;
let current_size = storage.digest_length(&layer_uuid).await.unwrap();
@ -70,7 +82,7 @@ pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String,
(0, written_size)
};
let full_uri = format!("{}/v2/{}/blobs/uploads/{}", &state.config.url, name, layer_uuid);
let full_uri = format!("{}/v2/{}/blobs/uploads/{}", state.config.get_url(), name, layer_uuid);
(
StatusCode::ACCEPTED,
[
@ -82,7 +94,11 @@ 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>>, state: State<Arc<AppState>>, body: Bytes) -> impl IntoResponse {
pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query<HashMap<String, String>>, Extension(auth): Extension<UserAuth>, state: State<Arc<AppState>>, body: Bytes) -> Response {
if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
return get_unauthenticated_response(&state.config);
}
let digest = query.get("digest").unwrap();
let storage = state.storage.lock().await;
@ -102,18 +118,26 @@ pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, S
(header::CONTENT_LENGTH, "0".to_string()),
(HeaderName::from_static("docker-upload-digest"), digest.to_owned())
]
)
).into_response()
}
pub async fn cancel_upload_delete(Path((_name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>) -> impl IntoResponse {
pub async fn cancel_upload_delete(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
return get_unauthenticated_response(&state.config);
}
let storage = state.storage.lock().await;
storage.delete_digest(&layer_uuid).await.unwrap();
// I'm not sure what this response should be, its not specified in the registry spec.
StatusCode::OK
StatusCode::OK.into_response()
}
pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>) -> impl IntoResponse {
pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Response {
if !does_user_have_permission(&state.database, auth.user.username, name.clone(), Permission::PUSH).await.unwrap() {
return get_unauthenticated_response(&state.config);
}
let storage = state.storage.lock().await;
let ending = storage.digest_length(&layer_uuid).await.unwrap().unwrap_or(0);
@ -124,5 +148,5 @@ pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, Str
(header::RANGE, format!("0-{}", ending)),
(HeaderName::from_static("docker-upload-digest"), layer_uuid)
]
)
).into_response()
}

View File

@ -1,10 +1,11 @@
use std::{collections::HashSet, ops::Deref, sync::Arc};
use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request}, middleware::Next, response::{Response, IntoResponse}};
use axum::{extract::{State, Path}, http::{StatusCode, HeaderMap, header, HeaderName, Request}, middleware::Next, response::{Response, IntoResponse}};
use tracing::debug;
use crate::app_state::AppState;
use crate::{app_state::AppState, dto::user::{Permission, RegistryUserType}, config::Config};
use crate::database::Database;
/// Temporary struct for storing auth information in memory.
pub struct MemoryAuthStorage {
@ -33,7 +34,7 @@ impl Deref for AuthToken {
type Rejection = (StatusCode, HeaderMap);
pub async fn require_auth<B>(State(state): State<Arc<AppState>>, mut request: Request<B>, next: Next<B>) -> Result<Response, Rejection> {
let bearer = format!("Bearer realm=\"http://localhost:3000/auth\"");
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.get_url());
let mut failure_headers = HeaderMap::new();
failure_headers.append(header::WWW_AUTHENTICATE, bearer.parse().unwrap());
failure_headers.append(HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".parse().unwrap());
@ -43,7 +44,7 @@ pub async fn require_auth<B>(State(state): State<Arc<AppState>>, mut request: Re
.ok_or((StatusCode::UNAUTHORIZED, failure_headers.clone()))?
.to_str()
.map_err(|_| (StatusCode::UNAUTHORIZED, failure_headers.clone()))?
); // TODO: Don't unwrap
);
let token = match auth.split_once(' ') {
Some((auth, token)) if auth == "Bearer" => token,
@ -53,23 +54,46 @@ pub async fn require_auth<B>(State(state): State<Arc<AppState>>, mut request: Re
};
// If the token is not valid, return an unauthorized response
let auth_storage = state.auth_storage.lock().await;
if !auth_storage.valid_tokens.contains(token) {
let bearer = format!("Bearer realm=\"http://localhost:3000/auth\"");
return Ok((
let database = &state.database;
if let Some(user) = database.verify_user_token(token.to_string()).await.unwrap() {
debug!("Authenticated user through middleware: {}", user.user.username);
request.extensions_mut().insert(user);
Ok(next.run(request).await)
} else {
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.get_url());
Ok((
StatusCode::UNAUTHORIZED,
[
( header::WWW_AUTHENTICATE, bearer ),
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() )
]
).into_response());
} else {
debug!("Client successfully authenticated!");
).into_response())
}
drop(auth_storage);
}
request.extensions_mut().insert(AuthToken(String::from(token)));
pub async fn does_user_have_permission(database: &impl Database, username: String, repository: String, permission: Permission) -> sqlx::Result<bool> {
let allowed_to = {
match database.get_user_registry_type(username.clone()).await.unwrap() {
Some(RegistryUserType::Admin) => true,
_ => match database.get_user_repo_permissions(username, repository).await.unwrap() {
Some(perms) => if perms.has_permission(permission) { true } else { false },
_ => false,
}
}
};
Ok(next.run(request).await)
Ok(allowed_to)
}
pub fn get_unauthenticated_response(config: &Config) -> Response {
let bearer = format!("Bearer realm=\"{}/auth\"", config.get_url());
(
StatusCode::UNAUTHORIZED,
[
( header::WWW_AUTHENTICATE, bearer ),
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() )
]
).into_response()
}

View File

@ -8,7 +8,7 @@ use std::env;
pub struct Config {
pub listen_address: String,
pub listen_port: String,
pub url: String,
pub url: Option<String>,
}
#[allow(dead_code)]
@ -37,11 +37,19 @@ impl Config {
.join(Toml::file(format!("{}", path)));
let mut config: Config = figment.extract()?;
if config.url.ends_with("/") {
config.url = config.url[..config.url.len() - 1].to_string();
if let Some(url) = config.url.as_mut() {
if url.ends_with("/") {
*url = url[..url.len() - 1].to_string();
}
}
Ok(config)
}
pub fn get_url(&self) -> String {
match &self.url {
Some(u) => u.clone(),
None => format!("http://{}:{}", self.listen_address, self.listen_port)
}
}
}

View File

@ -2,9 +2,9 @@ use async_trait::async_trait;
use sqlx::{Sqlite, Pool};
use tracing::debug;
use chrono::{DateTime, Utc, NaiveDateTime};
use chrono::{DateTime, Utc, NaiveDateTime, TimeZone};
use crate::dto::{Tag, user::User};
use crate::dto::{Tag, user::{User, RepositoryPermissions, RegistryUserType, Permission, UserAuth, TokenInfo}, RepositoryVisibility};
#[async_trait]
pub trait Database {
@ -40,8 +40,10 @@ pub trait Database {
// Repository related functions
async fn has_repository(&self, repository: &str) -> sqlx::Result<bool>;
async fn get_repository_visibility(&self, repository: &str) -> anyhow::Result<Option<RepositoryVisibility>>;
/// Create a repository
async fn save_repository(&self, repository: &str) -> sqlx::Result<()>;
async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owning_project: Option<String>) -> sqlx::Result<()>;
/// List all repositories.
/// If limit is not specified, a default limit of 1000 will be returned.
async fn list_repositories(&self, limit: Option<u32>, last_repo: Option<String>) -> sqlx::Result<Vec<String>>;
@ -50,6 +52,11 @@ pub trait Database {
/// User stuff
async fn create_user(&self, username: String, email: String, password_hash: String, password_salt: String) -> sqlx::Result<User>;
async fn verify_user_login(&self, username: String, password: String) -> anyhow::Result<bool>;
async fn get_user_registry_type(&self, username: String) -> anyhow::Result<Option<RegistryUserType>>;
async fn get_user_repo_permissions(&self, username: String, repository: String) -> anyhow::Result<Option<RepositoryPermissions>>;
async fn get_user_registry_usertype(&self, username: String) -> anyhow::Result<Option<RegistryUserType>>;
async fn store_user_token(&self, token: String, username: String, expiry: DateTime<Utc>, created_at: DateTime<Utc>) -> anyhow::Result<()>;
async fn verify_user_token(&self, token: String) -> anyhow::Result<Option<UserAuth>>;
}
#[async_trait]
@ -127,6 +134,7 @@ impl Database for Pool<Sqlite> {
}
async fn get_tag(&self, repository: &str, tag: &str) -> sqlx::Result<Option<Tag>> {
debug!("get tag");
let row: (String, i64, ) = match sqlx::query_as("SELECT image_manifest, last_updated FROM image_tags WHERE name = ? AND repository = ?")
.bind(tag)
.bind(repository)
@ -159,7 +167,7 @@ impl Database for Pool<Sqlite> {
}
async fn delete_tag(&self, repository: &str, tag: &str) -> sqlx::Result<()> {
sqlx::query("DELETE FROM image_tags WHERE name = ? AND repository = ?")
sqlx::query("DELETE FROM image_tags WHERE 'name' = ? AND repository = ?")
.bind(tag)
.bind(repository)
.execute(self).await?;
@ -224,11 +232,67 @@ impl Database for Pool<Sqlite> {
Ok(digests)
}
async fn save_repository(&self, repository: &str) -> sqlx::Result<()> {
sqlx::query("INSERT INTO repositories (name) VALUES (?)")
.bind(repository)
.execute(self).await?;
async fn has_repository(&self, repository: &str) -> sqlx::Result<bool> {
debug!("before query ig");
let row: (u32, ) = match sqlx::query_as("SELECT COUNT(1) repositories WHERE 'name' = ?")
.bind(repository)
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(false)
},
_ => {
return Err(e);
}
}
};
Ok(row.0 > 0)
}
async fn get_repository_visibility(&self, repository: &str) -> anyhow::Result<Option<RepositoryVisibility>> {
let row: (u32, ) = match sqlx::query_as("SELECT visibility FROM repositories WHERE 'name' = ?")
.bind(repository)
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(None)
},
_ => {
return Err(anyhow::Error::new(e));
}
}
};
Ok(Some(RepositoryVisibility::try_from(row.0)?))
}
async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owning_project: Option<String>) -> sqlx::Result<()> {
// ensure that the repository was not already created
if self.has_repository(repository).await? {
debug!("repo exists");
return Ok(());
}
debug!("repo does not exist");
match owning_project {
Some(owner) => {
sqlx::query("INSERT INTO repositories (name, visibility, owning_project) VALUES (?, ?, ?)")
.bind(repository)
.bind(visibility as u32)
.bind(owner)
.execute(self).await?;
},
None => {
sqlx::query("INSERT INTO repositories (name, visibility) VALUES (?, ?)")
.bind(repository)
.bind(visibility as u32)
.execute(self).await?;
}
}
Ok(())
}
@ -278,4 +342,95 @@ impl Database for Pool<Sqlite> {
Ok(bcrypt::verify(password, &row.0)?)
}
async fn get_user_registry_type(&self, username: String) -> anyhow::Result<Option<RegistryUserType>> {
let username = username.to_lowercase();
let row: (u32, ) = match sqlx::query_as("SELECT user_type FROM user_registry_permissions WHERE username = ?")
.bind(username)
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(None)
},
_ => {
return Err(anyhow::Error::new(e));
}
}
};
Ok(Some(RegistryUserType::try_from(row.0)?))
}
async fn get_user_repo_permissions(&self, username: String, repository: String) -> anyhow::Result<Option<RepositoryPermissions>> {
let username = username.to_lowercase();
let row: (u32, ) = match sqlx::query_as("SELECT repository_permissions FROM user_repo_permissions WHERE username = ? AND repository_name = ?")
.bind(username.clone())
.bind(repository.clone())
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(None)
},
_ => {
return Err(anyhow::Error::new(e));
}
}
};
let vis = self.get_repository_visibility(&repository).await?.unwrap();
// Also get the user type for the registry, if its admin return admin repository permissions
let utype = self.get_user_registry_usertype(username).await?.unwrap(); // unwrap should be safe
if utype == RegistryUserType::Admin {
Ok(Some(RepositoryPermissions::new(Permission::ADMIN.bits(), vis)))
} else {
Ok(Some(RepositoryPermissions::new(row.0, vis)))
}
}
async fn get_user_registry_usertype(&self, username: String) -> anyhow::Result<Option<RegistryUserType>> {
let username = username.to_lowercase();
let row: (u32, ) = sqlx::query_as("SELECT user_type FROM user_registry_permissions WHERE username = ?")
.bind(username)
.fetch_one(self).await?;
Ok(Some(RegistryUserType::try_from(row.0)?))
}
async fn store_user_token(&self, token: String, username: String, expiry: DateTime<Utc>, created_at: DateTime<Utc>) -> anyhow::Result<()> {
let username = username.to_lowercase();
let expiry = expiry.timestamp();
let created_at = created_at.timestamp();
sqlx::query("INSERT INTO user_tokens (token, username, expiry, created_at) VALUES (?, ?, ?, ?)")
.bind(token)
.bind(username)
.bind(expiry)
.bind(created_at)
.execute(self).await?;
Ok(())
}
async fn verify_user_token(&self, token: String) -> anyhow::Result<Option<UserAuth>> {
let token_row: (String, i64, i64, ) = sqlx::query_as("SELECT username, expiry, created_at FROM user_tokens WHERE token = ?")
.bind(token.clone())
.fetch_one(self).await?;
let (username, expiry, created_at) = (token_row.0, token_row.1, token_row.2);
let user_row: (String, ) = sqlx::query_as("SELECT email FROM users WHERE username = ?")
.bind(username.clone())
.fetch_one(self).await?;
let (expiry, created_at) = (Utc.timestamp_millis_opt(expiry).unwrap(), Utc.timestamp_millis_opt(created_at).unwrap());
let user = User::new(username, user_row.0);
let token = TokenInfo::new(token, expiry, created_at);
let auth = UserAuth::new(user, token);
Ok(Some(auth))
}
}

View File

@ -1,5 +1,14 @@
CREATE TABLE IF NOT EXISTS projects (
name TEXT NOT NULL UNIQUE PRIMARY KEY,
-- 0 = private, 1 = public
visibility INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS repositories (
name TEXT NOT NULL UNIQUE PRIMARY KEY
name TEXT NOT NULL UNIQUE PRIMARY KEY,
owning_project TEXT,
-- 0 = private, 1 = public
visibility INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS image_manifests (
@ -11,13 +20,16 @@ CREATE TABLE IF NOT EXISTS image_manifests (
CREATE TABLE IF NOT EXISTS image_tags (
name TEXT NOT NULL,
repository TEXT NOT NULL,
-- the image manifest for this tag
image_manifest TEXT NOT NULL,
-- the epoch timestamp fo when this image tag was last updated
last_updated BIGINT NOT NULL,
PRIMARY KEY (name, repository)
);
CREATE TABLE IF NOT EXISTS manifest_layers (
manifest TEXT NOT NULL,
-- the digest of the layer for this manifest
layer_digest TEXT NOT NULL,
PRIMARY KEY (manifest, layer_digest)
);
@ -25,6 +37,29 @@ CREATE TABLE IF NOT EXISTS manifest_layers (
CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL UNIQUE PRIMARY KEY,
email TEXT NOT NULL,
-- bcrypt hashed password
password_hash TEXT NOT NULL,
-- the salt generated along side the password hash
password_salt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_registry_permissions (
username TEXT NOT NULL UNIQUE PRIMARY KEY,
-- 0 = regular user, 1 = admin
user_type INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_repo_permissions (
username TEXT NOT NULL UNIQUE PRIMARY KEY,
-- name of repository that this user has these permissions in
repository_name TEXT NOT NULL,
-- bitwised integer storing permissions
repository_permissions INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_tokens (
token TEXT NOT NULL UNIQUE PRIMARY KEY,
username TEXT NOT NULL,
expiry BIGINT NOT NULL,
created_at BIGINT NOT NULL
);

View File

@ -22,4 +22,28 @@ impl Tag {
manifest_digest,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RepositoryVisibility {
Private = 0,
Public = 1
}
impl TryFrom<u32> for RepositoryVisibility {
type Error = anyhow::Error;
fn try_from(value: u32) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Private),
1 => Ok(Self::Public),
_ => Err(anyhow::anyhow!("Invalid value for RepositoryVisibility: `{}`", value)),
}
}
}
impl Into<u32> for RepositoryVisibility {
fn into(self) -> u32 {
self as u32
}
}

View File

@ -1,6 +1,14 @@
use std::collections::HashMap;
use bitflags::bitflags;
use chrono::{DateTime, Utc};
use super::RepositoryVisibility;
#[derive(Clone, Debug, PartialEq)]
pub struct User {
username: String,
email: String,
pub username: String,
pub email: String,
}
impl User {
@ -10,4 +18,101 @@ impl User {
email,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TokenInfo {
pub token: String,
pub expiry: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
impl TokenInfo {
pub fn new(token: String, expiry: DateTime<Utc>, created_at: DateTime<Utc>) -> Self {
Self {
token,
expiry,
created_at
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct UserAuth {
pub user: User,
pub token: TokenInfo,
}
impl UserAuth {
pub fn new(user: User, token: TokenInfo) -> Self {
Self {
user,
token,
}
}
}
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Permission: u32 {
const PULL = 0b0001;
const PUSH = 0b0010;
const EDIT = 0b0111;
const ADMIN = 0b1111;
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct RepositoryPermissions {
perms: u32,
visibility: RepositoryVisibility
}
impl RepositoryPermissions {
pub fn new(perms: u32, visibility: RepositoryVisibility) -> Self {
Self {
perms,
visibility
}
}
/// Check if this struct has this permission, use `RepositoryPermission`
/// which has constants for the permissions.
pub fn has_permission(&self, perm: Permission) -> bool {
let perm = perm.bits();
self.perms & perm == perm
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RegistryUserType {
Regular = 0,
Admin = 1
}
impl TryFrom<u32> for RegistryUserType {
type Error = anyhow::Error;
fn try_from(value: u32) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Regular),
1 => Ok(Self::Admin),
_ => Err(anyhow::anyhow!("Invalid value for RegistryUserType: `{}`", value)),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RegistryUser {
user_type: RegistryUserType,
repository_permissions: HashMap<String, RepositoryPermissions>,
}
impl RegistryUser {
pub fn new(user_type: RegistryUserType, repository_permissions: HashMap<String, RepositoryPermissions>) -> Self {
Self {
user_type,
repository_permissions,
}
}
}

View File

@ -57,20 +57,6 @@ async fn change_request_paths<B>(mut request: Request<B>, next: Next<B>) -> Resp
next.run(request).await
}
pub async fn auth_failure() -> impl IntoResponse {
let bearer = format!("Bearer realm=\"http://localhost:3000/token\"");
(
StatusCode::UNAUTHORIZED,
[
( header::WWW_AUTHENTICATE, bearer ),
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() )
]
).into_response()
//StatusCode::UNAUTHORIZED
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let pool = SqlitePoolOptions::new()