diff --git a/Pipfile b/Pipfile index a7693427..2fd3c282 100644 --- a/Pipfile +++ b/Pipfile @@ -23,6 +23,7 @@ brotli = "*" brotlicffi = "*" yt-dlp = "*" anyio = "*" +pycryptodome = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 3078ea4a..a9288fc6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "17668280c8e863033313b83f64240f6837e733957fbc2613767d297fee6c6fc2" + "sha256": "9fd5fe76e070040f587d879d9190d2bda03ef0a45a8ac970f408cb57bd578349" }, "pipfile-spec": 6, "requires": { @@ -27,11 +27,11 @@ }, "aiohappyeyeballs": { "hashes": [ - "sha256:19728772cb12263077982d2f55453babd8bec6a052a926cd5c0c42796da8bf62", - "sha256:6cac4f5dd6e34a9644e69cf9021ef679e4394f54e58a183056d12009e42ea9e3" + "sha256:0850b580748c7071db98bffff6d4c94028d0d3035acc20fd721a0ce7e8cac35d", + "sha256:18fde6204a76deeabc97c48bdd01d5801cfda5d6b9c8bbeb1aaaee9d648ca191" ], "markers": "python_version >= '3.9'", - "version": "==2.4.8" + "version": "==2.5.0" }, "aiohttp": { "hashes": [ @@ -842,6 +842,45 @@ "markers": "python_version >= '3.8'", "version": "==2.22" }, + "pycryptodome": { + "hashes": [ + "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8", + "sha256:0fa0a05a6a697ccbf2a12cec3d6d2650b50881899b845fac6e87416f8cb7e87d", + "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0", + "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93", + "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4", + "sha256:26412b21df30b2861424a6c6d5b1d8ca8107612a4cfa4d0183e71c5d200fb34a", + "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764", + "sha256:2cb635b67011bc147c257e61ce864879ffe6d03342dc74b6045059dfbdedafca", + "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e", + "sha256:3ba4cc304eac4d4d458f508d4955a88ba25026890e8abff9b60404f76a62c55e", + "sha256:4c26a2f0dc15f81ea3afa3b0c87b87e501f235d332b7f27e2225ecb80c0b1cdd", + "sha256:590ef0898a4b0a15485b05210b4a1c9de8806d3ad3d47f74ab1dc07c67a6827f", + "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6", + "sha256:6cce52e196a5f1d6797ff7946cdff2038d3b5f0aba4a43cb6bf46b575fd1b5bb", + "sha256:7cb087b8612c8a1a14cf37dd754685be9a8d9869bed2ffaaceb04850a8aeef7e", + "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1", + "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6", + "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a", + "sha256:8acd7d34af70ee63f9a849f957558e49a98f8f1634f86a59d2be62bb8e93f71c", + "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2", + "sha256:a1752eca64c60852f38bb29e2c86fca30d7672c024128ef5d70cc15868fa10f4", + "sha256:a3804675283f4764a02db05f5191eb8fec2bb6ca34d466167fc78a5f05bbe6b3", + "sha256:a4e74c522d630766b03a836c15bff77cb657c5fdf098abf8b1ada2aebc7d0819", + "sha256:a915597ffccabe902e7090e199a7bf7a381c5506a747d5e9d27ba55197a2c568", + "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53", + "sha256:cc2269ab4bce40b027b49663d61d816903a4bd90ad88cb99ed561aadb3888dd3", + "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8", + "sha256:dad9bf36eda068e89059d1f07408e397856be9511d7113ea4b586642a429a4fd", + "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b", + "sha256:f35e442630bc4bc2e1878482d6f59ea22e280d7121d7adeaedba58c23ab6386b", + "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297", + "sha256:ff99f952db3db2fbe98a0b355175f93ec334ba3d01bbde25ad3a5a33abc02b58" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==3.21.0" + }, "pyjson5": { "hashes": [ "sha256:00361a0aaabc3162992087d83bc067517c95c35810b631d9a40b80732003066e", @@ -1159,11 +1198,11 @@ }, "tzlocal": { "hashes": [ - "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2", - "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c" + "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", + "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" ], "markers": "python_version >= '3.9'", - "version": "==5.3" + "version": "==5.3.1" }, "wsproto": { "hashes": [ diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index be9f7059..e925b3dd 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -9,7 +9,7 @@ import random import time import uuid from collections.abc import Awaitable -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from urllib.parse import quote @@ -38,6 +38,8 @@ from .Utils import ( IGNORED_KEYS, StreamingError, arg_converter, + decrypt_data, + encrypt_data, get_file, get_mime_type, get_sidecar_subtitles, @@ -281,6 +283,18 @@ class HttpAPI(Common): if auth_header is None and request.query.get("apikey") is not None: auth_header = f"Basic {request.query.get('apikey')}" + if auth_header is None: + auth_cookie = request.cookies.get("auth") + if auth_cookie is not None: + try: + data = decrypt_data(auth_cookie, key=Config.get_instance().secret_key) + if data is not None: + LOG.info(f"Decoded cookie data '{data}'.") + data = base64.b64encode(data.encode()).decode() + auth_header = f"Basic {data}" + except Exception as e: + LOG.exception(e) + if auth_header is None: return web.json_response( status=web.HTTPUnauthorized.status_code, @@ -309,7 +323,26 @@ class HttpAPI(Common): data={"error": "Unauthorized (Invalid credentials)."}, status=web.HTTPUnauthorized.status_code ) - return await handler(request) + response = await handler(request) + if request.path != "/": + return response + + try: + response.set_cookie( + "auth", + encrypt_data( + f"{user_name}:{user_password}", + key=Config.get_instance().secret_key, + ), + max_age=60 * 60 * 24 * 7, + expires=datetime.now(UTC) + timedelta(days=7), + httponly=True, + samesite="Strict", + ) + except Exception as e: + LOG.exception(e) + + return response return middleware_handler diff --git a/app/library/Utils.py b/app/library/Utils.py index 66a5b616..61b7eab9 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -1,3 +1,4 @@ +import base64 import copy import glob import ipaddress @@ -13,6 +14,7 @@ from functools import lru_cache from typing import Any import yt_dlp +from Crypto.Cipher import AES from yt_dlp.networking.impersonate import ImpersonateTarget from .LogWrapper import LogWrapper @@ -615,3 +617,44 @@ def get_file(download_path: str, file: str | pathlib.Path) -> tuple[pathlib.Path return (realFile, 404) return (pathlib.Path(possibleFile), 302) + + +def encrypt_data(data: str, key: bytes) -> str: + """ + Encrypts data using AES-GCM + + Args: + data (str): The data to encrypt + key (bytes): The encryption key + + Returns: + str: The encrypted data as a base64 encoded string + + """ + iv = os.urandom(12) # AES-GCM requires a 12-byte IV + cipher = AES.new(key, AES.MODE_GCM, nonce=iv) + ciphertext, tag = cipher.encrypt_and_digest(data.encode()) + + return base64.urlsafe_b64encode(iv + ciphertext + tag).decode() + + +def decrypt_data(data: str, key: bytes) -> str: + """ + Decrypts AES-GCM encrypted data + + Args: + data (str): The encrypted data as a base64 encoded string + key (bytes): The encryption key + + Returns: + str: The decrypted data + + """ + try: + data = base64.urlsafe_b64decode(data) + iv, ciphertext, tag = data[:12], data[12:-16], data[-16:] + cipher = AES.new(key, AES.MODE_GCM, nonce=iv) + plaintext = cipher.decrypt_and_verify(ciphertext, tag) + return plaintext.decode() + except Exception: + return None diff --git a/app/library/config.py b/app/library/config.py index 624e1740..a331c1ca 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -1,4 +1,3 @@ -import json import logging import multiprocessing import os @@ -141,6 +140,9 @@ class Config: sentry_dsn: str | None = None "The Sentry DSN to use for error reporting." + secret_key: str + "The secret key to use for the application." + _manual_vars: tuple = ( "temp_path", "config_path", @@ -347,6 +349,18 @@ class Config: handler.setFormatter(formatter) logging.getLogger().addHandler(handler) + key_file: str = os.path.join(self.config_path, "secret.key") + + # save key as bytes. + if os.path.exists(key_file) and os.path.getsize(key_file) > 5: + with open(key_file,"rb") as f: + self.secret_key = f.read().strip() + else: + self.secret_key = os.urandom(32) + with open(key_file, "wb") as f: + f.write(self.secret_key) + + self.started = time.time() def _get_attributes(self) -> dict: diff --git a/ui/nuxt.config.ts b/ui/nuxt.config.ts index dfa430d3..feecec97 100644 --- a/ui/nuxt.config.ts +++ b/ui/nuxt.config.ts @@ -47,7 +47,7 @@ export default defineNuxtConfig({ { "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" }, { "name": "theme-color", "content": "#000000" }, ], - link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] + link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico?v=100' }] }, pageTransition: { name: 'page', mode: 'out-in' } }, diff --git a/ui/utils/index.js b/ui/utils/index.js index 292eabff..328fa532 100644 --- a/ui/utils/index.js +++ b/ui/utils/index.js @@ -235,14 +235,10 @@ const encodePath = item => { */ const request = (url, options = {}) => { const runtimeConfig = useRuntimeConfig() - const token = useStorage('token', null) options = options || {} options.method = options.method || 'GET' options.headers = options.headers || {} - - if (token && undefined === options.headers['Authorization']) { - options.headers['Authorization'] = 'Token ' + token.value - } + options.withCredentials = true if (undefined === options.headers['Content-Type']) { if (!(options?.body instanceof FormData)) {