quasarr 2.1.1__py3-none-any.whl → 2.1.2__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/packages/__init__.py +283 -233
- quasarr/providers/auth.py +84 -27
- 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.2.dist-info}/METADATA +1 -1
- {quasarr-2.1.1.dist-info → quasarr-2.1.2.dist-info}/RECORD +11 -11
- {quasarr-2.1.1.dist-info → quasarr-2.1.2.dist-info}/WHEEL +0 -0
- {quasarr-2.1.1.dist-info → quasarr-2.1.2.dist-info}/entry_points.txt +0 -0
- {quasarr-2.1.1.dist-info → quasarr-2.1.2.dist-info}/licenses/LICENSE +0 -0
- {quasarr-2.1.1.dist-info → quasarr-2.1.2.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
|
+
|
|
50
|
+
|
|
51
|
+
def _sign(data: bytes) -> bytes:
|
|
52
|
+
return hmac.new(_SECRET_KEY, data, hashlib.sha256).digest()
|
|
53
|
+
|
|
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()
|
|
42
61
|
|
|
43
62
|
|
|
44
|
-
def _create_session_cookie(user):
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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)}"
|
|
49
75
|
|
|
50
76
|
|
|
51
|
-
def
|
|
52
|
-
"""Verify a session cookie. Returns True if valid."""
|
|
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
|
|
|
@@ -246,6 +302,7 @@ def add_auth_hook(app, whitelist_prefixes=None):
|
|
|
246
302
|
# Check authentication
|
|
247
303
|
if is_form_auth():
|
|
248
304
|
if not check_form_auth():
|
|
305
|
+
_invalidate_cookie()
|
|
249
306
|
redirect(f'/login?next={path}')
|
|
250
307
|
else:
|
|
251
308
|
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;
|