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/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 _sign_cookie(user, expiry):
39
- """Create HMAC signature for cookie."""
40
- msg = f"{user}:{expiry}".encode()
41
- return hmac.new(AUTH_PASS.encode(), msg, hashlib.sha256).hexdigest()
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
- def _create_session_cookie(user):
45
- """Create a signed session cookie value."""
46
- expiry = int(time.time()) + COOKIE_MAX_AGE
47
- signature = _sign_cookie(user, expiry)
48
- return f"{user}:{expiry}:{signature}"
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 _verify_session_cookie(cookie_value):
52
- """Verify a session cookie. Returns True if valid."""
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
- parts = cookie_value.split(':')
55
- if len(parts) != 3:
56
- return False
57
- user, expiry, signature = parts
58
- expiry = int(expiry)
59
- if time.time() > expiry:
60
- return False
61
- expected_sig = _sign_cookie(user, expiry)
62
- return hmac.compare_digest(signature, expected_sig)
63
- except:
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
- cookie_value = _create_session_cookie(username)
182
- response.set_cookie(COOKIE_NAME, cookie_value, max_age=COOKIE_MAX_AGE, path='/', httponly=True)
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
- return _render_login_page(error="Invalid username or password")
241
+ _invalidate_cookie()
242
+ return _render_login_page("Invalid username or password")
186
243
 
187
244
 
188
245
  def _handle_logout():
189
- """Handle logout - clear cookie and redirect to login."""
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: 20px;
99
+ border-radius: 0.5rem;
96
100
  font-size: 0.9rem;
97
101
  font-weight: 500;
98
102
  margin: 8px 0;