Compare commits
No commits in common. "b46a7a844bce916c784678be15b894e215d0842b" and "618c04c29b8fc448f0d4c034a7beac8762a44f38" have entirely different histories.
b46a7a844b
...
618c04c29b
|
@ -321,7 +321,6 @@ dependencies = [
|
|||
"js-sys",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"time",
|
||||
"wasm-bindgen",
|
||||
"winapi",
|
||||
|
|
|
@ -14,7 +14,7 @@ uuid = { version = "1.3.1", features = [ "v4", "fast-rng" ] }
|
|||
sqlx = { version = "0.6.3", features = [ "runtime-tokio-rustls", "sqlite" ] }
|
||||
|
||||
bytes = "1.4.0"
|
||||
chrono = { version = "0.4.23", features = [ "serde" ] }
|
||||
chrono = "0.4.23"
|
||||
tokio = { version = "1.21.2", features = [ "fs", "macros" ] }
|
||||
tokio-util = { version = "0.7.7", features = [ "io" ] }
|
||||
|
||||
|
|
231
src/api/auth.rs
231
src/api/auth.rs
|
@ -1,17 +1,8 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::SystemTime,
|
||||
};
|
||||
use std::{sync::Arc, collections::{HashMap, BTreeMap}, time::SystemTime};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Form,
|
||||
};
|
||||
use axum::{extract::{Query, State}, response::{IntoResponse, Response}, http::{StatusCode, header}, Form};
|
||||
use axum_auth::AuthBasic;
|
||||
use chrono::{DateTime, Days, Utc};
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, span, Level};
|
||||
|
||||
|
@ -21,19 +12,12 @@ use sha2::Sha256;
|
|||
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{database::Database, dto::scope::Action};
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
dto::{
|
||||
scope::{Scope, ScopeType},
|
||||
user::{AuthToken, TokenInfo},
|
||||
RepositoryVisibility,
|
||||
},
|
||||
};
|
||||
use crate::{dto::{scope::Scope, user::TokenInfo}, app_state::AppState};
|
||||
use crate::database::Database;
|
||||
|
||||
use crate::auth::auth_challenge_response;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct TokenAuthRequest {
|
||||
user: Option<String>,
|
||||
password: Option<String>,
|
||||
|
@ -59,43 +43,39 @@ pub struct AuthResponse {
|
|||
issued_at: String,
|
||||
}
|
||||
|
||||
fn create_jwt_token(jwt_key: String, account: Option<&str>, scopes: Vec<Scope>) -> anyhow::Result<TokenInfo> {
|
||||
let key: Hmac<Sha256> = Hmac::new_from_slice(jwt_key.as_bytes())?;
|
||||
/// 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 = chrono::offset::Utc::now();
|
||||
|
||||
// Expire the token in a day
|
||||
let expiration = now.checked_add_days(Days::new(1)).unwrap();
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let jwtid = format!("{}", rng.gen::<u64>());
|
||||
|
||||
// empty account if they are not authenticated
|
||||
let account = account.map(|a| a.to_string()).unwrap_or(String::new());
|
||||
let now_secs = now.timestamp();
|
||||
|
||||
// Construct the claims for the token
|
||||
// TODO: Verify the token!
|
||||
let token = AuthToken::new(
|
||||
String::from("orca-registry__DEV"),
|
||||
account,
|
||||
String::from("reg"),
|
||||
expiration,
|
||||
now.clone(),
|
||||
now.clone(),
|
||||
jwtid,
|
||||
scopes,
|
||||
);
|
||||
let mut claims = BTreeMap::new();
|
||||
claims.insert("issuer", "orca-registry__DEV");
|
||||
claims.insert("subject", &account);
|
||||
//claims.insert("audience", auth.service);
|
||||
|
||||
let token_str = token.sign_with_key(&key)?;
|
||||
Ok(TokenInfo::new(token_str, expiration, now))
|
||||
let not_before = format!("{}", now_secs);
|
||||
let issued_at = format!("{}", now_secs);
|
||||
let expiration = format!("{}", now_secs + 86400); // 1 day
|
||||
claims.insert("notbefore", ¬_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);
|
||||
|
||||
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>>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppState>>, Query(params): Query<HashMap<String, String>>, form: Option<Form<AuthForm>>) -> Result<Response, StatusCode> {
|
||||
let mut auth = TokenAuthRequest {
|
||||
user: None,
|
||||
password: None,
|
||||
|
@ -108,19 +88,6 @@ pub async fn auth_basic_get(
|
|||
|
||||
let auth_method;
|
||||
|
||||
// Process all the scopes
|
||||
if let Some(scope) = params.get("scope") {
|
||||
// TODO: Handle multiple scopes
|
||||
match Scope::try_from(&scope[..]) {
|
||||
Ok(scope) => {
|
||||
auth.scope.push(scope);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If BasicAuth is provided, set the fields to it
|
||||
if let Some(AuthBasic((username, pass))) = basic_auth {
|
||||
auth.user = Some(username.clone());
|
||||
|
@ -146,89 +113,15 @@ pub async fn auth_basic_get(
|
|||
debug!("Read user authentication from a Form");
|
||||
|
||||
auth_method = "form";
|
||||
} else {
|
||||
// If no auth parameters were specified, check if the repository is public. if it is, respond with a token.
|
||||
|
||||
let is_public_access = {
|
||||
let mut res = vec![];
|
||||
|
||||
for scope in auth.scope.iter() {
|
||||
match scope.scope_type {
|
||||
ScopeType::Repository => {
|
||||
// check repository visibility
|
||||
let database = &state.database;
|
||||
match database.get_repository_visibility(&scope.name).await {
|
||||
Ok(Some(RepositoryVisibility::Public)) => res.push(Ok(true)),
|
||||
Ok(_) => res.push(Ok(false)),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failure to check repository visibility for {}! Err: {}",
|
||||
scope.name, e
|
||||
);
|
||||
|
||||
res.push(Err(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => res.push(Ok(false)),
|
||||
}
|
||||
}
|
||||
|
||||
// merge the booleans into a single bool, respond with errors if there are any.
|
||||
let res: Result<Vec<bool>, StatusCode> = res.into_iter().collect();
|
||||
res?.iter().all(|b| *b)
|
||||
};
|
||||
|
||||
if is_public_access {
|
||||
for scope in auth.scope.iter_mut() {
|
||||
// only retain Action::Pull
|
||||
scope.actions.retain(|a| *a == Action::Pull);
|
||||
}
|
||||
|
||||
let token = create_jwt_token(state.config.jwt_key.clone(), None, auth.scope)
|
||||
.map_err(|_| {
|
||||
error!("Failed to create jwt token!");
|
||||
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let token_str = token.token;
|
||||
let now_format = format!("{}", token.created_at.format("%+"));
|
||||
|
||||
let auth_response = AuthResponse {
|
||||
token: token_str.clone(),
|
||||
expires_in: 86400, // 1 day
|
||||
issued_at: now_format,
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&auth_response)
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
debug!("Created anonymous token for public scopes!");
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/json"),
|
||||
(header::AUTHORIZATION, &format!("Bearer {}", token_str)),
|
||||
],
|
||||
json_str,
|
||||
).into_response());
|
||||
} else {
|
||||
info!("Auth failure! Auth was not provided in either AuthBasic or Form!");
|
||||
|
||||
// Maybe BAD_REQUEST should be returned?
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
// Create logging span for the rest of this request
|
||||
let span = span!(
|
||||
Level::DEBUG,
|
||||
"auth",
|
||||
username = auth.user.clone(),
|
||||
auth_method
|
||||
);
|
||||
let span = span!(Level::DEBUG, "auth", username = auth.user.clone(), auth_method);
|
||||
let _enter = span.enter();
|
||||
|
||||
debug!("Parsed user auth request");
|
||||
|
@ -238,10 +131,7 @@ pub async fn auth_basic_get(
|
|||
if let Some(account) = params.get("account") {
|
||||
if let Some(user) = &auth.user {
|
||||
if account != user {
|
||||
error!(
|
||||
"`user` and `account` are not the same!!! (user: {}, account: {})",
|
||||
user, account
|
||||
);
|
||||
error!("`user` and `account` are not the same!!! (user: {}, account: {})", user, account);
|
||||
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
@ -255,6 +145,20 @@ pub async fn auth_basic_get(
|
|||
auth.service = Some(service.clone());
|
||||
}
|
||||
|
||||
// Process all the scopes
|
||||
if let Some(scope) = params.get("scope") {
|
||||
|
||||
// TODO: Handle multiple scopes
|
||||
match Scope::try_from(&scope[..]) {
|
||||
Ok(scope) => {
|
||||
auth.scope.push(scope);
|
||||
},
|
||||
Err(_) => {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get offline token and attempt to convert it to a boolean
|
||||
if let Some(offline_token) = params.get("offline_token") {
|
||||
if let Ok(b) = offline_token.parse::<bool>() {
|
||||
|
@ -271,30 +175,19 @@ pub async fn auth_basic_get(
|
|||
if let (Some(account), Some(password)) = (&auth.account, auth.password) {
|
||||
// Ensure that the password is correct
|
||||
let mut auth_driver = state.auth_checker.lock().await;
|
||||
if !auth_driver
|
||||
.verify_user_login(account.clone(), password)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
if !auth_driver.verify_user_login(account.clone(), password).await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? {
|
||||
debug!("Authentication failed, incorrect password!");
|
||||
|
||||
// TODO: Multiple scopes
|
||||
let scope = auth.scope
|
||||
.first()
|
||||
.and_then(|s| Some(s.clone()));
|
||||
|
||||
// TODO: Dont unwrap, find a way to return multiple scopes
|
||||
return Ok(auth_challenge_response(
|
||||
&state.config,
|
||||
scope,
|
||||
));
|
||||
return Ok(auth_challenge_response(&state.config, Some(auth.scope.first().unwrap().clone())));
|
||||
}
|
||||
drop(auth_driver);
|
||||
|
||||
debug!("User password is correct");
|
||||
|
||||
let now = SystemTime::now();
|
||||
let token = create_jwt_token(state.config.jwt_key.clone(), Some(account), vec![])
|
||||
let token = create_jwt_token(account)
|
||||
.map_err(|_| {
|
||||
error!("Failed to create jwt token!");
|
||||
|
||||
|
@ -315,18 +208,11 @@ pub async fn auth_basic_get(
|
|||
issued_at: now_format,
|
||||
};
|
||||
|
||||
let json_str =
|
||||
serde_json::to_string(&auth_response).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let json_str = serde_json::to_string(&auth_response)
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let database = &state.database;
|
||||
database
|
||||
.store_user_token(
|
||||
token_str.clone(),
|
||||
account.clone(),
|
||||
token.expiry,
|
||||
token.created_at,
|
||||
)
|
||||
.await
|
||||
database.store_user_token(token_str.clone(), account.clone(), token.expiry, token.created_at).await
|
||||
.map_err(|_| {
|
||||
error!("Failed to store user token in database!");
|
||||
|
||||
|
@ -338,11 +224,10 @@ pub async fn auth_basic_get(
|
|||
StatusCode::OK,
|
||||
[
|
||||
( header::CONTENT_TYPE, "application/json" ),
|
||||
(header::AUTHORIZATION, &format!("Bearer {}", token_str)),
|
||||
( header::AUTHORIZATION, &format!("Bearer {}", token_str) )
|
||||
],
|
||||
json_str,
|
||||
)
|
||||
.into_response());
|
||||
json_str
|
||||
).into_response());
|
||||
}
|
||||
|
||||
info!("Auth failure! Not enough information given to create auth token!");
|
||||
|
|
|
@ -6,12 +6,13 @@ use axum::http::{StatusCode, HeaderName, header};
|
|||
use tracing::log::warn;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::auth::access_denied_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;
|
||||
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> {
|
||||
|
@ -19,13 +20,10 @@ pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>
|
|||
let calculated_hash = sha256::digest(body.clone());
|
||||
let calculated_digest = format!("sha256:{}", calculated_hash);
|
||||
|
||||
// anonymous users wouldn't be able to get to this point, so it should be safe to unwrap.
|
||||
let user = auth.user.unwrap();
|
||||
|
||||
let database = &state.database;
|
||||
|
||||
// Create the image repository and save the image manifest. This repository will be private by default
|
||||
database.save_repository(&name, RepositoryVisibility::Private, Some(user.email), None).await?;
|
||||
database.save_repository(&name, RepositoryVisibility::Private, Some(auth.user.email), None).await?;
|
||||
database.save_manifest(&name, &calculated_digest, &body).await?;
|
||||
|
||||
// If the reference is not a digest, then it must be a tag name.
|
||||
|
@ -59,7 +57,20 @@ 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>>) -> Result<Response, AppError> {
|
||||
pub async fn pull_manifest_get(Path((name, reference)): 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));
|
||||
}
|
||||
}
|
||||
|
||||
let database = &state.database;
|
||||
let digest = match Digest::is_digest(&reference) {
|
||||
true => reference.clone(),
|
||||
|
@ -95,8 +106,21 @@ 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>>) -> Result<Response, AppError> {
|
||||
debug!("start of head");
|
||||
pub async fn manifest_exists_head(Path((name, reference)): 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));
|
||||
}
|
||||
drop(auth_driver);
|
||||
} else {
|
||||
let database = &state.database;
|
||||
if database.get_repository_visibility(&name).await? != Some(RepositoryVisibility::Public) {
|
||||
return Ok(access_denied_response(&state.config));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the digest from the reference path.
|
||||
let database = &state.database;
|
||||
let digest = match Digest::is_digest(&reference) {
|
||||
|
@ -109,7 +133,6 @@ pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)
|
|||
}
|
||||
}
|
||||
};
|
||||
debug!("found digest: {}", digest);
|
||||
|
||||
let manifest_content = database.get_manifest(&name, &digest).await?;
|
||||
if manifest_content.is_none() {
|
||||
|
@ -119,8 +142,6 @@ pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)
|
|||
}
|
||||
let manifest_content = manifest_content.unwrap();
|
||||
|
||||
debug!("got content");
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
|
@ -133,7 +154,13 @@ 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)>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
pub async fn delete_manifest(Path((name, reference)): 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);
|
||||
|
||||
let database = &state.database;
|
||||
let digest = match Digest::is_digest(&reference) {
|
||||
true => {
|
||||
|
|
|
@ -17,7 +17,7 @@ pub mod auth;
|
|||
/// full endpoint: `/v2/`
|
||||
pub async fn version_check(_state: State<Arc<AppState>>) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[
|
||||
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0" ),
|
||||
]
|
||||
|
|
|
@ -3,7 +3,7 @@ use ldap3::{LdapConnAsync, Ldap, Scope, SearchEntry};
|
|||
use sqlx::{Pool, Sqlite};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{config::LdapConnectionConfig, dto::{user::{Permission, LoginSource, RegistryUserType}, RepositoryVisibility}, database::Database};
|
||||
use crate::{config::LdapConnectionConfig, dto::{user::{Permission, LoginSource, RegistryUserType, self}, RepositoryVisibility}, database::Database};
|
||||
|
||||
use super::AuthDriver;
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
pub mod ldap_driver;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request, Method}, middleware::Next, response::{Response, IntoResponse}};
|
||||
|
||||
|
@ -82,8 +82,59 @@ where
|
|||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthToken(pub String);
|
||||
|
||||
impl Deref for AuthToken {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
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=\"{}/auth\"", state.config.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());
|
||||
|
||||
let auth = String::from(
|
||||
request.headers().get(header::AUTHORIZATION)
|
||||
.ok_or((StatusCode::UNAUTHORIZED, failure_headers.clone()))?
|
||||
.to_str()
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, failure_headers.clone()))?
|
||||
);
|
||||
|
||||
let token = match auth.split_once(' ') {
|
||||
Some((auth, token)) if auth == "Bearer" => token,
|
||||
// This line would allow empty tokens
|
||||
//_ if auth == "Bearer" => Ok(AuthToken(None)),
|
||||
_ => return Err( (StatusCode::UNAUTHORIZED, failure_headers) ),
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
request.extensions_mut().insert(user);
|
||||
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.url());
|
||||
Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[
|
||||
( header::WWW_AUTHENTICATE, bearer ),
|
||||
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() )
|
||||
]
|
||||
).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
|
@ -122,19 +173,11 @@ pub async fn check_auth<B>(State(state): State<Arc<AppState>>, auth: Option<User
|
|||
// note: url is relative to /v2
|
||||
let url = request.uri().to_string();
|
||||
|
||||
if url == "/" {
|
||||
// if auth is none, then the client needs to authenticate
|
||||
if auth.is_none() {
|
||||
if url == "/" && auth.is_none() {
|
||||
debug!("Responding to /v2/ with an auth challenge");
|
||||
return Ok(auth_challenge_response(config, None));
|
||||
}
|
||||
|
||||
debug!("user is authed");
|
||||
|
||||
// the client is authenticating right now
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
let url_split: Vec<&str> = url.split("/").skip(1).collect();
|
||||
let target_name = url_split[0].replace("%2F", "/");
|
||||
let target_type = url_split[1];
|
||||
|
@ -173,8 +216,7 @@ pub async fn check_auth<B>(State(state): State<Arc<AppState>>, auth: Option<User
|
|||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(user) = &auth.user {
|
||||
match auth_checker.user_has_permission(user.email.clone(), target_name.clone(), permission, vis).await {
|
||||
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) => {
|
||||
|
@ -183,19 +225,6 @@ pub async fn check_auth<B>(State(state): State<Arc<AppState>>, auth: Option<User
|
|||
return Err((StatusCode::INTERNAL_SERVER_ERROR, HeaderMap::new()));
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// anonymous users can ONLY pull from public repos
|
||||
if permission != Permission::PULL {
|
||||
return Ok(access_denied_response(config));
|
||||
}
|
||||
|
||||
// ensure the repo is public
|
||||
let database = &state.database;
|
||||
if let Some(RepositoryVisibility::Private) = database.get_repository_visibility(&target_name).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, HeaderMap::new()))? {
|
||||
return Ok(access_denied_response(config));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Unhandled auth check for '{target_type}'!!"); // TODO
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
use anyhow::anyhow;
|
||||
use figment::{Figment, providers::{Env, Toml, Format}};
|
||||
use figment_cliarg_provider::FigmentCliArgsProvider;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
@ -70,8 +71,6 @@ pub struct Config {
|
|||
pub database: DatabaseConfig,
|
||||
pub storage: StorageConfig,
|
||||
pub tls: Option<TlsConfig>,
|
||||
#[serde(skip)]
|
||||
pub jwt_key: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
use async_trait::async_trait;
|
||||
use rand::{Rng, distributions::Alphanumeric};
|
||||
use sqlx::{Sqlite, Pool};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||
use chrono::{DateTime, Utc, NaiveDateTime, TimeZone};
|
||||
|
||||
use crate::dto::{Tag, user::{User, RepositoryPermissions, RegistryUserType, Permission, UserAuth, LoginSource}, RepositoryVisibility};
|
||||
use crate::dto::{Tag, user::{User, RepositoryPermissions, RegistryUserType, Permission, UserAuth, TokenInfo, LoginSource}, RepositoryVisibility};
|
||||
|
||||
#[async_trait]
|
||||
pub trait Database {
|
||||
|
||||
// Digest related functions
|
||||
|
||||
/// Create the tables in the database
|
||||
async fn create_schema(&self) -> anyhow::Result<()>;
|
||||
|
||||
async fn get_jwt_secret(&self) -> anyhow::Result<String>;
|
||||
|
||||
// Tag related functions
|
||||
|
||||
/// Get tags associated with a repository
|
||||
|
@ -63,67 +61,20 @@ pub trait Database {
|
|||
async fn get_user_repo_permissions(&self, email: String, repository: String) -> anyhow::Result<Option<RepositoryPermissions>>;
|
||||
async fn get_user_registry_usertype(&self, email: String) -> anyhow::Result<Option<RegistryUserType>>;
|
||||
async fn store_user_token(&self, token: String, email: String, expiry: DateTime<Utc>, created_at: DateTime<Utc>) -> anyhow::Result<()>;
|
||||
#[deprecated = "Tokens are now verified using a secret"]
|
||||
async fn verify_user_token(&self, token: String) -> anyhow::Result<Option<UserAuth>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Database for Pool<Sqlite> {
|
||||
async fn create_schema(&self) -> anyhow::Result<()> {
|
||||
let orca_version = "0.1.0";
|
||||
let schema_version = "0.0.1";
|
||||
|
||||
let row: Option<(u32, )> = match sqlx::query_as("SELECT COUNT(1) FROM orca WHERE \"schema_version\" = ?")
|
||||
.bind(schema_version)
|
||||
.fetch_one(self).await {
|
||||
Ok(row) => Some(row),
|
||||
Err(e) => match e {
|
||||
sqlx::Error::RowNotFound => {
|
||||
None
|
||||
},
|
||||
// ignore no such table errors
|
||||
sqlx::Error::Database(b) if b.message().starts_with("no such table") => None,
|
||||
_ => {
|
||||
return Err(anyhow::Error::new(e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query(include_str!("schemas/schema.sql"))
|
||||
.execute(self).await?;
|
||||
|
||||
debug!("Created database schema");
|
||||
|
||||
if row.is_none() || row.unwrap().0 == 0 {
|
||||
let jwt_sec: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(16)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
// create schema
|
||||
// TODO: Check if needed
|
||||
/* sqlx::query(include_str!("schemas/schema.sql"))
|
||||
.execute(self).await?;
|
||||
debug!("Created database schema"); */
|
||||
|
||||
sqlx::query("INSERT INTO orca(orca_version, schema_version, jwt_secret) VALUES (?, ?, ?)")
|
||||
.bind(orca_version)
|
||||
.bind(schema_version)
|
||||
.bind(jwt_sec)
|
||||
.execute(self).await?;
|
||||
debug!("Inserted information about orca!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_jwt_secret(&self) -> anyhow::Result<String> {
|
||||
let rows: (String, ) = sqlx::query_as("SELECT jwt_secret FROM orca WHERE id = (SELECT max(id) FROM orca)")
|
||||
.fetch_one(self).await?;
|
||||
|
||||
Ok(rows.0)
|
||||
}
|
||||
|
||||
async fn link_manifest_layer(&self, manifest_digest: &str, layer_digest: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("INSERT INTO manifest_layers(manifest, layer_digest) VALUES (?, ?)")
|
||||
.bind(manifest_digest)
|
||||
|
@ -418,7 +369,6 @@ impl Database for Pool<Sqlite> {
|
|||
}
|
||||
|
||||
async fn get_user(&self, email: String) -> anyhow::Result<Option<User>> {
|
||||
debug!("getting user");
|
||||
let email = email.to_lowercase();
|
||||
let row: (String, u32) = match sqlx::query_as("SELECT username, login_source FROM users WHERE email = ?")
|
||||
.bind(email.clone())
|
||||
|
@ -563,7 +513,50 @@ impl Database for Pool<Sqlite> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_user_token(&self, _token: String) -> anyhow::Result<Option<UserAuth>> {
|
||||
panic!("ERR: Database::verify_user_token is deprecated!")
|
||||
async fn verify_user_token(&self, token: String) -> anyhow::Result<Option<UserAuth>> {
|
||||
let token_row: (String, i64, i64,) = match sqlx::query_as("SELECT email, expiry, created_at FROM user_tokens WHERE token = ?")
|
||||
.bind(token.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 (email, expiry, created_at) = (token_row.0, token_row.1, token_row.2);
|
||||
|
||||
let user_row: (String, u32) = match sqlx::query_as("SELECT username, login_source FROM users WHERE email = ?")
|
||||
.bind(email.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 user_row: (String, u32) = sqlx::query_as("SELECT email, login_source FROM users WHERE email = ?")
|
||||
.bind(email.clone())
|
||||
.fetch_one(self).await?; */
|
||||
|
||||
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(user_row.0, email, LoginSource::try_from(user_row.1)?);
|
||||
let token = TokenInfo::new(token, expiry, created_at);
|
||||
let auth = UserAuth::new(user, token);
|
||||
|
||||
Ok(Some(auth))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +1,3 @@
|
|||
CREATE TABLE IF NOT EXISTS orca (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
orca_version TEXT NOT NULL,
|
||||
schema_version TEXT NOT NULL,
|
||||
jwt_secret TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
name TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||
-- 0 = private, 1 = public
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
use anyhow::anyhow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, de::Visitor};
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub enum ScopeType {
|
||||
#[default]
|
||||
Unknown,
|
||||
#[serde(rename = "repository")]
|
||||
Repository,
|
||||
}
|
||||
|
||||
|
@ -20,13 +19,11 @@ impl fmt::Display for ScopeType {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub enum Action {
|
||||
#[default]
|
||||
None,
|
||||
#[serde(rename = "push")]
|
||||
Push,
|
||||
#[serde(rename = "pull")]
|
||||
Pull,
|
||||
}
|
||||
|
||||
|
@ -40,19 +37,18 @@ impl fmt::Display for Action {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Scope {
|
||||
#[serde(rename = "type")]
|
||||
pub scope_type: ScopeType,
|
||||
pub name: String,
|
||||
pub actions: Vec<Action>,
|
||||
scope_type: ScopeType,
|
||||
path: String,
|
||||
actions: Vec<Action>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn new(scope_type: ScopeType, path: String, actions: &[Action]) -> Self {
|
||||
Self {
|
||||
scope_type,
|
||||
name: path,
|
||||
path,
|
||||
actions: actions.to_vec(),
|
||||
}
|
||||
}
|
||||
|
@ -66,7 +62,7 @@ impl fmt::Display for Scope {
|
|||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
write!(f, "{}:{}:{}", self.scope_type, self.name, actions)
|
||||
write!(f, "{}:{}:{}", self.scope_type, self.path, actions)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -97,7 +93,7 @@ impl TryFrom<&str> for Scope {
|
|||
|
||||
Ok(Scope {
|
||||
scope_type,
|
||||
name: String::from(path),
|
||||
path: String::from(path),
|
||||
actions
|
||||
})
|
||||
} else {
|
||||
|
@ -106,3 +102,67 @@ impl TryFrom<&str> for Scope {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScopeVisitor {
|
||||
|
||||
}
|
||||
|
||||
impl<'de> Visitor<'de> for ScopeVisitor {
|
||||
type Value = Scope;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a Scope in the format of `repository:samalba/my-app:pull,push`.")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error {
|
||||
println!("Start of visit_str!");
|
||||
|
||||
let res = match Scope::try_from(val) {
|
||||
Ok(val) => Ok(val),
|
||||
Err(e) => Err(serde::de::Error::custom(format!("{}", e)))
|
||||
};
|
||||
|
||||
res
|
||||
|
||||
|
||||
|
||||
/* let splits: Vec<&str> = val.split(":").collect();
|
||||
if splits.len() == 3 {
|
||||
let scope_type = match splits[0] {
|
||||
"repository" => ScopeType::Repository,
|
||||
_ => {
|
||||
return Err(serde::de::Error::custom(format!("Invalid scope type: `{}`!", splits[0])));
|
||||
}
|
||||
};
|
||||
|
||||
let path = splits[1];
|
||||
|
||||
let actions: Result<Vec<Action>, E> = splits[2]
|
||||
.split(",")
|
||||
.map(|a| match a {
|
||||
"pull" => Ok(Action::Pull),
|
||||
"push" => Ok(Action::Push),
|
||||
_ => Err(serde::de::Error::custom(format!("Invalid action: `{}`!", a))),
|
||||
}).collect();
|
||||
let actions = actions?;
|
||||
|
||||
Ok(Scope {
|
||||
scope_type,
|
||||
path: String::from(path),
|
||||
actions
|
||||
})
|
||||
} else {
|
||||
Err(serde::de::Error::custom("Malformed scope string!"))
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Scope {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de> {
|
||||
deserializer.deserialize_str(ScopeVisitor {})
|
||||
}
|
||||
}
|
|
@ -4,15 +4,11 @@ use async_trait::async_trait;
|
|||
use axum::{http::{StatusCode, header, HeaderName, HeaderMap, request::Parts}, extract::FromRequestParts};
|
||||
use bitflags::bitflags;
|
||||
use chrono::{DateTime, Utc};
|
||||
use hmac::{Hmac, digest::KeyInit};
|
||||
use jwt::VerifyWithKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{app_state::AppState, database::Database};
|
||||
|
||||
use super::{RepositoryVisibility, scope::Scope};
|
||||
use super::RepositoryVisibility;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum LoginSource {
|
||||
|
@ -49,50 +45,6 @@ impl User {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthToken {
|
||||
#[serde(rename = "iss")]
|
||||
pub issuer: String,
|
||||
|
||||
#[serde(rename = "sub")]
|
||||
pub subject: String,
|
||||
|
||||
#[serde(rename = "aud")]
|
||||
pub audience: String,
|
||||
|
||||
#[serde(rename = "exp")]
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub expiration: DateTime<Utc>,
|
||||
|
||||
#[serde(rename = "nbf")]
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub not_before: DateTime<Utc>,
|
||||
|
||||
#[serde(rename = "iat")]
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub issued_at: DateTime<Utc>,
|
||||
|
||||
#[serde(rename = "jti")]
|
||||
pub jwt_id: String,
|
||||
|
||||
pub access: Vec<Scope>,
|
||||
}
|
||||
|
||||
impl AuthToken {
|
||||
pub fn new(issuer: String, subject: String, audience: String, expiration: DateTime<Utc>, not_before: DateTime<Utc>, issued_at: DateTime<Utc>, jwt_id: String, access: Vec<Scope>) -> Self {
|
||||
Self {
|
||||
issuer,
|
||||
subject,
|
||||
audience,
|
||||
expiration,
|
||||
not_before,
|
||||
issued_at,
|
||||
jwt_id,
|
||||
access
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TokenInfo {
|
||||
pub token: String,
|
||||
|
@ -112,12 +64,12 @@ impl TokenInfo {
|
|||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UserAuth {
|
||||
pub user: Option<User>,
|
||||
pub token: AuthToken,
|
||||
pub user: User,
|
||||
pub token: TokenInfo,
|
||||
}
|
||||
|
||||
impl UserAuth {
|
||||
pub fn new(user: Option<User>, token: AuthToken) -> Self {
|
||||
pub fn new(user: User, token: TokenInfo) -> Self {
|
||||
Self {
|
||||
user,
|
||||
token,
|
||||
|
@ -135,6 +87,8 @@ impl FromRequestParts<Arc<AppState>> for UserAuth {
|
|||
failure_headers.append(header::WWW_AUTHENTICATE, bearer.parse().unwrap());
|
||||
failure_headers.append(HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".parse().unwrap());
|
||||
|
||||
debug!("starting UserAuth request parts");
|
||||
|
||||
let auth = String::from(
|
||||
parts.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
|
@ -143,6 +97,8 @@ impl FromRequestParts<Arc<AppState>> for UserAuth {
|
|||
.map_err(|_| (StatusCode::UNAUTHORIZED, failure_headers.clone()))?
|
||||
);
|
||||
|
||||
debug!("got auth header");
|
||||
|
||||
let token = match auth.split_once(' ') {
|
||||
Some((auth, token)) if auth == "Bearer" => token,
|
||||
// This line would allow empty tokens
|
||||
|
@ -150,36 +106,15 @@ impl FromRequestParts<Arc<AppState>> for UserAuth {
|
|||
_ => return Err( (StatusCode::UNAUTHORIZED, failure_headers) ),
|
||||
};
|
||||
|
||||
debug!("got token");
|
||||
|
||||
// If the token is not valid, return an unauthorized response
|
||||
let jwt_key: Hmac<Sha256> = Hmac::new_from_slice(state.config.jwt_key.as_bytes())
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, HeaderMap::new()) )?;
|
||||
|
||||
match VerifyWithKey::<AuthToken>::verify_with_key(token, &jwt_key) {
|
||||
Ok(token) => {
|
||||
// attempt to get the user
|
||||
if !token.subject.is_empty() {
|
||||
let database = &state.database;
|
||||
if let Ok(Some(user)) = database.get_user(token.subject.clone()).await {
|
||||
return Ok(UserAuth::new(Some(user), token));
|
||||
} else {
|
||||
debug!("failure to get user from token: {:?}", token);
|
||||
}
|
||||
} else {
|
||||
return Ok(UserAuth::new(None, token));
|
||||
}
|
||||
if let Ok(Some(user)) = database.verify_user_token(token.to_string()).await {
|
||||
debug!("Authenticated user through request extractor: {}", user.user.username);
|
||||
|
||||
/* let database = &state.database;
|
||||
if let Ok(user) = database.get_user(token.subject.clone()).await {
|
||||
return Ok(UserAuth::new(user, token));
|
||||
Ok(user)
|
||||
} else {
|
||||
debug!("failure to get user from token: {:?}", token);
|
||||
} */
|
||||
},
|
||||
Err(e) => {
|
||||
debug!("Failure to verify user token: '{}'", e);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Failure to verify user token, responding with auth realm");
|
||||
|
||||
Err((
|
||||
|
@ -188,6 +123,7 @@ impl FromRequestParts<Arc<AppState>> for UserAuth {
|
|||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
|
|
@ -69,7 +69,7 @@ async fn change_request_paths<B>(mut request: Request<B>, next: Next<B>) -> Resu
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut config = Config::new()
|
||||
let config = Config::new()
|
||||
.expect("Failure to parse config!");
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
|
@ -92,9 +92,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
.connect_with(connection_options).await?;
|
||||
pool.create_schema().await?;
|
||||
|
||||
// set jwt key
|
||||
config.jwt_key = pool.get_jwt_secret().await?;
|
||||
|
||||
let storage_driver: Mutex<Box<dyn StorageDriver>> = match &config.storage {
|
||||
StorageConfig::Filesystem(fs) => {
|
||||
Mutex::new(Box::new(FilesystemDriver::new(&fs.path)))
|
||||
|
|
Loading…
Reference in New Issue