#124: Add feature to secure the API of self-hosted resources with Password

This commit is contained in:
mhdzumair
2024-02-24 23:53:07 +05:30
parent c4303c532d
commit dfa7b0ea0a
8 changed files with 91 additions and 23 deletions
+12 -5
View File
@@ -21,7 +21,7 @@ from api import middleware
from db import database, crud, schemas
from db.config import settings
from streaming_providers.routes import router as streaming_provider_router
from utils import crypto, torrent, poster, const, rate_limiter
from utils import crypto, torrent, poster, const, wrappers
from utils.parser import generate_manifest
logging.basicConfig(
@@ -106,7 +106,7 @@ async def get_home(request: Request):
@app.get("/health", tags=["health"])
@rate_limiter.exclude
@wrappers.exclude
async def health(request: Request):
return {"status": "healthy"}
@@ -154,12 +154,14 @@ async def configure(
"catalogs": sorted_catalogs,
"resolutions": const.RESOLUTIONS,
"sorting_options": const.TORRENT_SORTING_PRIORITY,
"authentication_required": settings.api_password is not None,
},
)
@app.get("/manifest.json", tags=["manifest"])
@app.get("/{secret_str}/manifest.json", tags=["manifest"])
@wrappers.auth_required
async def get_manifest(
response: Response, user_data: schemas.UserData = Depends(get_user_data)
):
@@ -212,7 +214,8 @@ async def get_manifest(
response_model_by_alias=False,
tags=["catalog"],
)
@rate_limiter.rate_limit(150, 300, "catalog")
@wrappers.auth_required
@wrappers.rate_limit(150, 300, "catalog")
async def get_catalog(
response: Response,
request: Request,
@@ -271,6 +274,7 @@ async def get_catalog(
response_model_exclude_none=True,
response_model_by_alias=False,
)
@wrappers.auth_required
async def search_meta(
response: Response,
catalog_type: Literal["movie", "series", "tv"],
@@ -301,6 +305,7 @@ async def search_meta(
response_model_exclude_none=True,
response_model_by_alias=False,
)
@wrappers.auth_required
async def get_meta(
catalog_type: Literal["movie", "series", "tv"],
meta_id: str,
@@ -359,7 +364,8 @@ async def get_meta(
response_model_exclude_none=True,
tags=["stream"],
)
@rate_limiter.rate_limit(10, 60 * 60, "stream")
@wrappers.auth_required
@wrappers.rate_limit(10, 60 * 60, "stream")
async def get_streams(
catalog_type: Literal["movie", "series", "tv"],
video_id: str,
@@ -387,13 +393,14 @@ async def get_streams(
@app.post("/encrypt-user-data", tags=["user_data"])
@wrappers.rate_limit(30, 60 * 5, "user_data")
async def encrypt_user_data(user_data: schemas.UserData):
encrypted_str = crypto.encrypt_user_data(user_data)
return {"encrypted_str": encrypted_str}
@app.get("/poster/{catalog_type}/{mediafusion_id}.jpg", tags=["poster"])
@rate_limiter.exclude
@wrappers.exclude
async def get_poster(
catalog_type: Literal["movie", "series", "tv"],
mediafusion_id: str,
+12 -1
View File
@@ -61,10 +61,21 @@ class SecureLoggingMiddleware(BaseHTTPMiddleware):
class UserDataMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable):
await find_route_handler(request.app, request)
endpoint = await find_route_handler(request.app, request)
secret_str = request.path_params.get("secret_str")
# Decrypt and parse the UserData from secret_str
user_data = crypto.decrypt_user_data(secret_str)
# validate api password if set
if settings.api_password:
is_auth_required = getattr(endpoint, "auth_required", False)
if is_auth_required and user_data.api_password != settings.api_password:
return Response(
content="Unauthorized",
status_code=401,
headers=const.NO_CACHE_HEADERS,
)
# Attach UserData to request state for access in endpoints
request.scope["user"] = user_data
+1
View File
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
prowlarr_immediate_max_process_time: int = 15
adult_content_regex_keywords: str = r"(^|\b|\s)(18\+|adult|porn|sex|xxx|nude|naked|erotic|sexy|18\s*plus)(\b|\s|$|[._-])"
enable_rate_limit: bool = True
api_password: str | None = None
class Config:
env_file = ".env"
+1
View File
@@ -98,6 +98,7 @@ class UserData(BaseModel):
max_streams_per_resolution: int = 3
show_full_torrent_name: bool = False
torrent_sorting_priority: list[str] = Field(default=const.TORRENT_SORTING_PRIORITY)
api_password: str | None = None
@model_validator(mode="after")
def validate_selected_resolutions(self) -> "UserData":
+25 -1
View File
@@ -247,10 +247,34 @@
</div>
{% if authentication_required %}
<!-- API Password Configuration -->
<div class="section-container">
<h4 class="section-header">API Security Configuration</h4>
<hr class="section-divider">
<!-- API Password Input -->
<div class="mb-3">
<label for="api_password">API Password: <span class="bi bi-question-circle" data-bs-toggle="tooltip" data-bs-placement="top"
title="Enter the API password you configured in the environment variables."></span></label>
<div class="input-group">
<input class="form-control" type="password" id="api_password" name="api_password" placeholder="Enter API Password">
<button class="btn btn-outline-secondary" type="button" id="toggleApiPassword">
<span id="toggleApiPasswordIcon" class="bi bi-eye"></span>
</button>
<div class="invalid-feedback">
API Password is required.
</div>
</div>
</div>
</div>
{% endif %}
<!-- Fallback URL Display - Initially hidden -->
<div id="fallbackUrlContainer" class="fallback-url-container" style="display: none;">
<label for="fallbackUrl">Installation URL <span class="bi bi-question-circle" data-bs-toggle="tooltip" data-bs-placement="top"
title="This is the URL that you can use to install the addon in Stremio."></span></label>
title="This is the URL that you can use to install the addon in Stremio."></span></label>
<textarea id="fallbackUrl" class="form-control" readonly></textarea>
<p>Please manually copy the URL above and paste it in the Stremio search bar. Do not share this URL with unknown persons.</p>
</div>
+28 -14
View File
@@ -253,6 +253,7 @@ function getUserData() {
}
};
// Validate and collect streaming provider data
if (provider) {
if (servicesRequiringCredentials.includes(provider)) {
validateInput('username', document.getElementById('username').value);
@@ -269,22 +270,29 @@ function getUserData() {
streamingProviderData = null;
}
// Collect and validate other user data
const maxSizeSlider = document.getElementById('max_size_slider');
const maxSizeValue = maxSizeSlider.value;
const maxSize = maxSizeSlider.max;
const maxSizeBytes = maxSizeValue === maxSize ? 'inf' : maxSizeValue;
const maxStreamsPerResolution = document.getElementById('maxStreamsPerResolution').value;
validateInput('maxStreamsPerResolution', maxStreamsPerResolution && !isNaN(maxStreamsPerResolution) && maxStreamsPerResolution > 0);
let apiPassword = null;
// Check for API Password if authentication is required
if (document.getElementById('api_password')) {
validateInput('api_password', document.getElementById('api_password').value);
apiPassword = document.getElementById('api_password').value;
}
if (!isValid) {
return null; // Return null if validation fails
}
// Collect and return the rest of the user data
const selectedSortingOptions = Array.from(document.querySelectorAll('#streamSortOrder .form-check-input:checked')).map(el => el.value);
const torrentDisplayOption = document.querySelector('input[name="torrentDisplayOption"]:checked').value;
// Return user data if all validations pass
return {
streaming_provider: streamingProviderData,
selected_catalogs: Array.from(document.querySelectorAll('input[name="selected_catalogs"]:checked')).map(el => el.value),
@@ -294,6 +302,7 @@ function getUserData() {
max_streams_per_resolution: maxStreamsPerResolution,
torrent_sorting_priority: selectedSortingOptions,
show_full_torrent_name: torrentDisplayOption === 'fullName',
api_password: apiPassword,
};
}
@@ -306,6 +315,20 @@ function displayFallbackUrl(url) {
textarea.focus();
}
function setupPasswordToggle(passwordInputId, toggleButtonId, toggleIconId) {
document.getElementById(toggleButtonId).addEventListener('click', function (_) {
const passwordInput = document.getElementById(passwordInputId);
const passwordIcon = document.getElementById(toggleIconId);
if (passwordInput.type === "password") {
passwordInput.type = "text";
passwordIcon.className = "bi bi-eye-slash";
} else {
passwordInput.type = "password";
passwordIcon.className = "bi bi-eye";
}
});
}
// ---- Event Listeners ----
document.getElementById('provider_token').addEventListener('input', function () {
@@ -315,18 +338,6 @@ document.getElementById('provider_token').addEventListener('input', function ()
// Event listener for the slider
document.getElementById('max_size_slider').addEventListener('input', updateSizeOutput);
document.getElementById('togglePassword').addEventListener('click', function (e) {
const passwordInput = document.getElementById('password');
const passwordIcon = document.getElementById('togglePasswordIcon');
if (passwordInput.type === "password") {
passwordInput.type = "text";
passwordIcon.className = "bi bi-eye-slash";
} else {
passwordInput.type = "password";
passwordIcon.className = "bi bi-eye";
}
});
oAuthBtn.addEventListener('click', async function () {
const provider = document.getElementById('provider_service').value;
oAuthBtn.disabled = true;
@@ -400,6 +411,9 @@ document.addEventListener('DOMContentLoaded', function () {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
setupPasswordToggle('password', 'togglePassword', 'togglePasswordIcon');
setupPasswordToggle('api_password', 'toggleApiPassword', 'toggleApiPasswordIcon');
if (navigator.share) {
document.getElementById('shareBtn').style.display = 'block';
} else {
+3 -2
View File
@@ -23,13 +23,14 @@ from streaming_providers.realdebrid.utils import get_direct_link_from_realdebrid
from streaming_providers.seedr.api import router as seedr_router
from streaming_providers.seedr.utils import get_direct_link_from_seedr
from streaming_providers.torbox.utils import get_direct_link_from_torbox
from utils import crypto, torrent, rate_limiter, const
from utils import crypto, torrent, wrappers, const
router = APIRouter()
@router.get("/{secret_str}/stream", tags=["streaming_provider"])
@rate_limiter.exclude
@wrappers.exclude
@wrappers.auth_required
async def streaming_provider_endpoint(
secret_str: str,
info_hash: str,
@@ -30,3 +30,12 @@ def exclude(func):
wrapper.exclude = True
return wrapper
def auth_required(func):
@wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
wrapper.auth_required = True
return wrapper