quasarr 2.1.1__py3-none-any.whl → 2.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- quasarr/api/__init__.py +4 -1
- quasarr/api/packages/__init__.py +283 -233
- quasarr/providers/auth.py +90 -28
- quasarr/providers/html_templates.py +5 -1
- quasarr/providers/obfuscated.py +10 -10
- quasarr/providers/version.py +1 -1
- {quasarr-2.1.1.dist-info → quasarr-2.1.3.dist-info}/METADATA +1 -1
- {quasarr-2.1.1.dist-info → quasarr-2.1.3.dist-info}/RECORD +12 -12
- {quasarr-2.1.1.dist-info → quasarr-2.1.3.dist-info}/WHEEL +0 -0
- {quasarr-2.1.1.dist-info → quasarr-2.1.3.dist-info}/entry_points.txt +0 -0
- {quasarr-2.1.1.dist-info → quasarr-2.1.3.dist-info}/licenses/LICENSE +0 -0
- {quasarr-2.1.1.dist-info → quasarr-2.1.3.dist-info}/top_level.txt +0 -0
quasarr/providers/auth.py
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import base64
|
|
6
6
|
import hashlib
|
|
7
7
|
import hmac
|
|
8
|
+
import json
|
|
8
9
|
import os
|
|
9
10
|
import time
|
|
10
11
|
from functools import wraps
|
|
@@ -24,6 +25,9 @@ AUTH_TYPE = os.environ.get('AUTH', '').lower()
|
|
|
24
25
|
COOKIE_NAME = 'quasarr_session'
|
|
25
26
|
COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
26
27
|
|
|
28
|
+
# Stable secret derived from PASS (restart-safe)
|
|
29
|
+
_SECRET_KEY = hashlib.sha256(AUTH_PASS.encode('utf-8')).digest()
|
|
30
|
+
|
|
27
31
|
|
|
28
32
|
def is_auth_enabled():
|
|
29
33
|
"""Check if authentication is enabled (both USER and PASS set)."""
|
|
@@ -35,32 +39,75 @@ def is_form_auth():
|
|
|
35
39
|
return AUTH_TYPE == 'form'
|
|
36
40
|
|
|
37
41
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
def _b64encode(data: bytes) -> str:
|
|
43
|
+
return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _b64decode(data: str) -> bytes:
|
|
47
|
+
padding = '=' * (-len(data) % 4)
|
|
48
|
+
return base64.urlsafe_b64decode(data + padding)
|
|
49
|
+
|
|
42
50
|
|
|
51
|
+
def _sign(data: bytes) -> bytes:
|
|
52
|
+
return hmac.new(_SECRET_KEY, data, hashlib.sha256).digest()
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
|
|
55
|
+
def _mask_user(user: str) -> str:
|
|
56
|
+
"""
|
|
57
|
+
One-way masked user identifier.
|
|
58
|
+
Stable across restarts, not reversible.
|
|
59
|
+
"""
|
|
60
|
+
return hashlib.sha256(f"user:{user}".encode('utf-8')).hexdigest()
|
|
49
61
|
|
|
50
62
|
|
|
51
|
-
def
|
|
52
|
-
"""
|
|
63
|
+
def _create_session_cookie(user: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Stateless, signed cookie.
|
|
66
|
+
Stores only masked user + expiry.
|
|
67
|
+
"""
|
|
68
|
+
payload = {
|
|
69
|
+
"u": _mask_user(user),
|
|
70
|
+
"exp": int(time.time()) + COOKIE_MAX_AGE,
|
|
71
|
+
}
|
|
72
|
+
raw = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
|
73
|
+
sig = _sign(raw)
|
|
74
|
+
return f"{_b64encode(raw)}.{_b64encode(sig)}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _invalidate_cookie():
|
|
53
78
|
try:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
response.delete_cookie(COOKIE_NAME, path='/')
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _verify_session_cookie(value: str) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Verify signature, expiry, and masked user.
|
|
87
|
+
On ANY failure → force logout (cookie deletion).
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
if not value or '.' not in value:
|
|
91
|
+
raise ValueError
|
|
92
|
+
|
|
93
|
+
raw_b64, sig_b64 = value.split('.', 1)
|
|
94
|
+
raw = _b64decode(raw_b64)
|
|
95
|
+
sig = _b64decode(sig_b64)
|
|
96
|
+
|
|
97
|
+
if not hmac.compare_digest(sig, _sign(raw)):
|
|
98
|
+
raise ValueError
|
|
99
|
+
|
|
100
|
+
payload = json.loads(raw.decode('utf-8'))
|
|
101
|
+
|
|
102
|
+
if payload.get("u") != _mask_user(AUTH_USER):
|
|
103
|
+
raise ValueError
|
|
104
|
+
|
|
105
|
+
if int(time.time()) > int(payload.get("exp", 0)):
|
|
106
|
+
raise ValueError
|
|
107
|
+
|
|
108
|
+
return True
|
|
109
|
+
except Exception:
|
|
110
|
+
_invalidate_cookie()
|
|
64
111
|
return False
|
|
65
112
|
|
|
66
113
|
|
|
@@ -80,7 +127,7 @@ def check_basic_auth():
|
|
|
80
127
|
def check_form_auth():
|
|
81
128
|
"""Check session cookie. Returns True if valid."""
|
|
82
129
|
cookie = request.get_cookie(COOKIE_NAME)
|
|
83
|
-
return cookie and _verify_session_cookie(cookie)
|
|
130
|
+
return bool(cookie and _verify_session_cookie(cookie))
|
|
84
131
|
|
|
85
132
|
|
|
86
133
|
def require_basic_auth():
|
|
@@ -178,16 +225,25 @@ def _handle_login_post():
|
|
|
178
225
|
next_url = request.forms.get('next', '/')
|
|
179
226
|
|
|
180
227
|
if username == AUTH_USER and password == AUTH_PASS:
|
|
181
|
-
|
|
182
|
-
|
|
228
|
+
cookie = _create_session_cookie(username)
|
|
229
|
+
secure_flag = request.url.startswith('https://')
|
|
230
|
+
response.set_cookie(
|
|
231
|
+
COOKIE_NAME,
|
|
232
|
+
cookie,
|
|
233
|
+
max_age=COOKIE_MAX_AGE,
|
|
234
|
+
path='/',
|
|
235
|
+
httponly=True,
|
|
236
|
+
secure=secure_flag,
|
|
237
|
+
samesite='Lax'
|
|
238
|
+
)
|
|
183
239
|
redirect(next_url)
|
|
184
240
|
else:
|
|
185
|
-
|
|
241
|
+
_invalidate_cookie()
|
|
242
|
+
return _render_login_page("Invalid username or password")
|
|
186
243
|
|
|
187
244
|
|
|
188
245
|
def _handle_logout():
|
|
189
|
-
|
|
190
|
-
response.delete_cookie(COOKIE_NAME, path='/')
|
|
246
|
+
_invalidate_cookie()
|
|
191
247
|
redirect('/login')
|
|
192
248
|
|
|
193
249
|
|
|
@@ -217,7 +273,7 @@ def add_auth_routes(app):
|
|
|
217
273
|
return _handle_logout()
|
|
218
274
|
|
|
219
275
|
|
|
220
|
-
def add_auth_hook(app, whitelist_prefixes=None):
|
|
276
|
+
def add_auth_hook(app, whitelist_prefixes=None, whitelist_suffixes=None):
|
|
221
277
|
"""Add authentication hook to a Bottle app.
|
|
222
278
|
|
|
223
279
|
Args:
|
|
@@ -243,9 +299,15 @@ def add_auth_hook(app, whitelist_prefixes=None):
|
|
|
243
299
|
if path.startswith(prefix):
|
|
244
300
|
return
|
|
245
301
|
|
|
302
|
+
# Check whitelist suffixes:
|
|
303
|
+
for suffix in whitelist_suffixes:
|
|
304
|
+
if path.endswith(suffix):
|
|
305
|
+
return
|
|
306
|
+
|
|
246
307
|
# Check authentication
|
|
247
308
|
if is_form_auth():
|
|
248
309
|
if not check_form_auth():
|
|
310
|
+
_invalidate_cookie()
|
|
249
311
|
redirect(f'/login?next={path}')
|
|
250
312
|
else:
|
|
251
313
|
if not check_basic_auth():
|
|
@@ -86,13 +86,17 @@ def render_centered_html(inner_content, footer_content=""):
|
|
|
86
86
|
margin-top: 0;
|
|
87
87
|
color: var(--setup-border);
|
|
88
88
|
}
|
|
89
|
+
a.action-card,
|
|
90
|
+
a.action-card {
|
|
91
|
+
text-decoration: none !important;
|
|
92
|
+
}
|
|
89
93
|
/* Status pill styling */
|
|
90
94
|
.status-pill {
|
|
91
95
|
display: inline-flex;
|
|
92
96
|
align-items: center;
|
|
93
97
|
gap: 6px;
|
|
94
98
|
padding: 6px 12px;
|
|
95
|
-
border-radius:
|
|
99
|
+
border-radius: 0.5rem;
|
|
96
100
|
font-size: 0.9rem;
|
|
97
101
|
font-weight: 500;
|
|
98
102
|
margin: 8px 0;
|