tinyjukebox 0.47.0__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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
tinyjukebox/web/auth.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Access control for the rest api: password hashes, users file, sessions and login throttling."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import stat
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeGuard
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MAX_USERNAME_LENGTH",
|
|
19
|
+
"MIN_PASSWORD_LENGTH",
|
|
20
|
+
"ROLES",
|
|
21
|
+
"LoginThrottle",
|
|
22
|
+
"Session",
|
|
23
|
+
"Sessions",
|
|
24
|
+
"UsersFile",
|
|
25
|
+
"has_role",
|
|
26
|
+
"hash_password",
|
|
27
|
+
"valid_username",
|
|
28
|
+
"verify_password",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
ROLES = ("user", "admin") # admin may do everything a user may do
|
|
32
|
+
MIN_PASSWORD_LENGTH = 8
|
|
33
|
+
MAX_USERNAME_LENGTH = 64
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def valid_username(name: object) -> TypeGuard[str]:
|
|
37
|
+
"""A name for a user of the page - the same rule on the page and for ``tinyjukebox-users``: not empty, no blanks
|
|
38
|
+
around it, no ``:``, at most :data:`MAX_USERNAME_LENGTH` characters."""
|
|
39
|
+
return (
|
|
40
|
+
isinstance(name, str)
|
|
41
|
+
and bool(name)
|
|
42
|
+
and name == name.strip()
|
|
43
|
+
and ":" not in name
|
|
44
|
+
and len(name) <= MAX_USERNAME_LENGTH
|
|
45
|
+
and name.isprintable()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# scrypt parameters - n=2**14 needs 16 MB and ~50 ms on a Raspberry Pi 3
|
|
50
|
+
_SCRYPT_N = 2**14
|
|
51
|
+
_SCRYPT_R = 8
|
|
52
|
+
_SCRYPT_P = 1
|
|
53
|
+
|
|
54
|
+
Clock = Callable[[], float]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _b64(data: bytes) -> str:
|
|
58
|
+
return base64.b64encode(data).decode("ascii")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hash_password(password: str) -> str:
|
|
62
|
+
"""'scrypt$n$r$p$salt$hash' (salt and hash base64 encoded)."""
|
|
63
|
+
salt = secrets.token_bytes(16)
|
|
64
|
+
digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32)
|
|
65
|
+
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${_b64(salt)}${_b64(digest)}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify_password(password: str, password_hash: object) -> bool:
|
|
69
|
+
"""Constant time comparison; malformed hashes never match."""
|
|
70
|
+
try:
|
|
71
|
+
algorithm, n, r, p, salt, digest = str(password_hash).split("$")
|
|
72
|
+
if algorithm != "scrypt":
|
|
73
|
+
return False
|
|
74
|
+
expected = base64.b64decode(digest)
|
|
75
|
+
candidate = hashlib.scrypt(
|
|
76
|
+
password.encode("utf-8"),
|
|
77
|
+
salt=base64.b64decode(salt),
|
|
78
|
+
n=int(n),
|
|
79
|
+
r=int(r),
|
|
80
|
+
p=int(p),
|
|
81
|
+
dklen=len(expected),
|
|
82
|
+
maxmem=128 * 1024 * 1024,
|
|
83
|
+
)
|
|
84
|
+
except (ValueError, TypeError):
|
|
85
|
+
return False
|
|
86
|
+
return hmac.compare_digest(candidate, expected)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# compared against if the user is unknown - login takes as long as for a known user (no user enumeration)
|
|
90
|
+
_DUMMY_HASH = hash_password(secrets.token_hex(16))
|
|
91
|
+
|
|
92
|
+
# one password check at a time: each one needs 16 MB, and parallel logins (one thread per connection) would multiply
|
|
93
|
+
# that - and pass the login throttle before the first failure has been counted
|
|
94
|
+
_VERIFY_LOCK = threading.Lock()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_role(role: str, required_role: str) -> bool:
|
|
98
|
+
return ROLES.index(role) >= ROLES.index(required_role)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class UsersFile:
|
|
102
|
+
"""Users are stored in a yaml file (managed with ``tinyjukebox-users``)::
|
|
103
|
+
|
|
104
|
+
users:
|
|
105
|
+
mama:
|
|
106
|
+
password-hash: scrypt$16384$8$1$...
|
|
107
|
+
role: admin
|
|
108
|
+
|
|
109
|
+
The file is reloaded if it has been changed - adding or removing users needs no restart. The web page changes
|
|
110
|
+
it with :meth:`change`. A session ends when its user has a new password, wherever it was set
|
|
111
|
+
(:meth:`session_role`).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, filename: str, logger: logging.Logger) -> None:
|
|
115
|
+
self.filename = filename
|
|
116
|
+
self._logger = logger
|
|
117
|
+
self._lock = threading.Lock()
|
|
118
|
+
self._mtime: int | None = None
|
|
119
|
+
self._users: dict[str, dict[str, Any]] = {}
|
|
120
|
+
self._reload()
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def read(filename: str) -> dict[str, dict[str, Any]]:
|
|
124
|
+
""":raises ValueError: if the file is not a valid users file."""
|
|
125
|
+
try:
|
|
126
|
+
with open(filename, encoding="utf-8") as f:
|
|
127
|
+
data = yaml.safe_load(f) or {}
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
return {}
|
|
130
|
+
users = data.get("users") or {} if isinstance(data, dict) else None
|
|
131
|
+
if not isinstance(users, dict):
|
|
132
|
+
raise ValueError(f"'users' in '{filename}' must be a mapping.")
|
|
133
|
+
for name, user in users.items():
|
|
134
|
+
if not isinstance(user, dict) or "password-hash" not in user or user.get("role") not in ROLES:
|
|
135
|
+
raise ValueError(f"user '{name}' in '{filename}' needs a password-hash and a role ({'/'.join(ROLES)}).")
|
|
136
|
+
return users
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def write(filename: str, users: dict[str, dict[str, Any]]) -> None:
|
|
140
|
+
"""Write the users file (mode 600, atomically)."""
|
|
141
|
+
os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
|
|
142
|
+
temp = filename + ".tmp"
|
|
143
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
144
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
145
|
+
yaml.safe_dump({"users": users}, f, default_flow_style=False, allow_unicode=True)
|
|
146
|
+
os.replace(temp, filename)
|
|
147
|
+
|
|
148
|
+
def _reload(self) -> None:
|
|
149
|
+
try:
|
|
150
|
+
st = os.stat(self.filename)
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
if self._mtime is not None or not self._users:
|
|
153
|
+
self._logger.warning(
|
|
154
|
+
"users file '%s' not found - nobody can log in. "
|
|
155
|
+
"create users with 'tinyjukebox-users -f %s add NAME'.",
|
|
156
|
+
self.filename,
|
|
157
|
+
self.filename,
|
|
158
|
+
)
|
|
159
|
+
self._users = {}
|
|
160
|
+
self._mtime = None
|
|
161
|
+
return
|
|
162
|
+
if st.st_mtime_ns == self._mtime:
|
|
163
|
+
return
|
|
164
|
+
if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
165
|
+
self._logger.warning("users file '%s' is readable by other users - use 'chmod 600'.", self.filename)
|
|
166
|
+
try:
|
|
167
|
+
self._users = self.read(self.filename)
|
|
168
|
+
except (ValueError, yaml.YAMLError) as e:
|
|
169
|
+
self._logger.error("cannot read users file: %s - keeping the previous users.", e)
|
|
170
|
+
self._mtime = st.st_mtime_ns
|
|
171
|
+
|
|
172
|
+
def roles(self) -> dict[str, str]:
|
|
173
|
+
"""The role of every user."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._reload()
|
|
176
|
+
return {name: str(user["role"]) for name, user in self._users.items()}
|
|
177
|
+
|
|
178
|
+
def change(self, change: Callable[[dict[str, dict[str, Any]]], None]) -> None:
|
|
179
|
+
"""Apply ``change`` to the users of the file and write it back. Whatever ``change`` raises leaves the file
|
|
180
|
+
as it is.
|
|
181
|
+
|
|
182
|
+
:raises ValueError: if the file is not a valid users file."""
|
|
183
|
+
with self._lock:
|
|
184
|
+
users = self.read(self.filename)
|
|
185
|
+
change(users)
|
|
186
|
+
self.write(self.filename, users)
|
|
187
|
+
# not via _reload: on a coarse file system the new file may have the mtime of the old one
|
|
188
|
+
self._users = users
|
|
189
|
+
self._mtime = os.stat(self.filename).st_mtime_ns
|
|
190
|
+
|
|
191
|
+
def authenticate(self, username: str, password: str) -> str | None:
|
|
192
|
+
"""The role of the user - None for unknown users or wrong passwords."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
self._reload()
|
|
195
|
+
user = self._users.get(username)
|
|
196
|
+
with _VERIFY_LOCK:
|
|
197
|
+
if user is None:
|
|
198
|
+
verify_password(password, _DUMMY_HASH)
|
|
199
|
+
return None
|
|
200
|
+
return str(user["role"]) if verify_password(password, user["password-hash"]) else None
|
|
201
|
+
|
|
202
|
+
def get_role(self, username: str) -> str | None:
|
|
203
|
+
"""Current role of the user - None if the user has been removed in the meantime."""
|
|
204
|
+
with self._lock:
|
|
205
|
+
self._reload()
|
|
206
|
+
user = self._users.get(username)
|
|
207
|
+
return None if user is None else str(user["role"])
|
|
208
|
+
|
|
209
|
+
def credential(self, username: str) -> str | None:
|
|
210
|
+
"""What a session remembers of the password of its user: it changes with every new password, set on the page
|
|
211
|
+
or with ``tinyjukebox-users``. None for an unknown user."""
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._reload()
|
|
214
|
+
user = self._users.get(username)
|
|
215
|
+
return None if user is None else _credential(str(user["password-hash"]))
|
|
216
|
+
|
|
217
|
+
def session_role(self, session: "Session") -> str | None:
|
|
218
|
+
"""The current role of the user of the session - None if the user has been removed or has a new password
|
|
219
|
+
since the session began: then the session has ended."""
|
|
220
|
+
with self._lock:
|
|
221
|
+
self._reload()
|
|
222
|
+
user = self._users.get(session.username)
|
|
223
|
+
if user is None or _credential(str(user["password-hash"])) != session.credential:
|
|
224
|
+
return None
|
|
225
|
+
return str(user["role"])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _credential(password_hash: str) -> str:
|
|
229
|
+
"""A fingerprint of a password hash - a session keeps this, not the hash."""
|
|
230
|
+
return hashlib.sha256(password_hash.encode("utf-8")).hexdigest()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Session:
|
|
234
|
+
def __init__(self, username: str, role: str, expires: float, active: float, credential: str = "") -> None:
|
|
235
|
+
self.username = username
|
|
236
|
+
self.role = role
|
|
237
|
+
self.expires = expires
|
|
238
|
+
self.active = active # when the user did something last
|
|
239
|
+
self.credential = credential # of the password the session began with (UsersFile.credential)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Sessions:
|
|
243
|
+
"""Random session tokens; only their sha256 hash is kept. Sessions expire after ``timeout`` seconds without
|
|
244
|
+
use, and after ``idle_timeout`` seconds in which the user did nothing (0: never) - an open page asks all the time,
|
|
245
|
+
so only what it reports as done by the user counts for that."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, timeout: float, clock: Clock = time.monotonic, idle_timeout: float = 0) -> None:
|
|
248
|
+
self._timeout = timeout
|
|
249
|
+
self.idle_timeout = idle_timeout
|
|
250
|
+
self._clock = clock
|
|
251
|
+
self._sessions: dict[str, Session] = {}
|
|
252
|
+
self._lock = threading.Lock()
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _key(token: str) -> str:
|
|
256
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
257
|
+
|
|
258
|
+
def create(self, username: str, role: str, credential: str = "") -> str:
|
|
259
|
+
token = secrets.token_urlsafe(32)
|
|
260
|
+
with self._lock:
|
|
261
|
+
self._purge()
|
|
262
|
+
now = self._clock()
|
|
263
|
+
self._sessions[self._key(token)] = Session(username, role, now + self._timeout, now, credential)
|
|
264
|
+
return token
|
|
265
|
+
|
|
266
|
+
def get(self, token: str | None, idle: float | None = 0) -> Session | None:
|
|
267
|
+
"""The session of the token (its expiry is extended) - None for unknown or expired tokens.
|
|
268
|
+
|
|
269
|
+
``idle``: the user did something last this many seconds ago - 0 for a request that is an action itself, None
|
|
270
|
+
for one that says nothing about the user (the page asking by itself)."""
|
|
271
|
+
if not token:
|
|
272
|
+
return None
|
|
273
|
+
key = self._key(token)
|
|
274
|
+
with self._lock:
|
|
275
|
+
session = self._sessions.get(key)
|
|
276
|
+
if session is None:
|
|
277
|
+
return None
|
|
278
|
+
now = self._clock()
|
|
279
|
+
if idle is not None:
|
|
280
|
+
session.active = max(session.active, now - max(idle, 0))
|
|
281
|
+
if session.expires < now or (self.idle_timeout > 0 and now - session.active > self.idle_timeout):
|
|
282
|
+
del self._sessions[key]
|
|
283
|
+
return None
|
|
284
|
+
session.expires = now + self._timeout
|
|
285
|
+
return session
|
|
286
|
+
|
|
287
|
+
def remove(self, token: str) -> None:
|
|
288
|
+
with self._lock:
|
|
289
|
+
self._sessions.pop(self._key(token), None)
|
|
290
|
+
|
|
291
|
+
def remove_user(self, username: str, keep: str | None = None, credential: str | None = None) -> None:
|
|
292
|
+
"""Ends all sessions of the user - except the one of the token ``keep``, which gets ``credential`` if given:
|
|
293
|
+
the user who has just set a new password stays logged in."""
|
|
294
|
+
keep_key = self._key(keep) if keep else None
|
|
295
|
+
with self._lock:
|
|
296
|
+
for key in [k for k, s in self._sessions.items() if s.username == username and k != keep_key]:
|
|
297
|
+
del self._sessions[key]
|
|
298
|
+
kept = self._sessions.get(keep_key) if keep_key else None
|
|
299
|
+
if kept is not None and kept.username == username and credential is not None:
|
|
300
|
+
kept.credential = credential
|
|
301
|
+
|
|
302
|
+
def _purge(self) -> None:
|
|
303
|
+
"""Forget the sessions that have expired. Whether a session in which the user did nothing for too long ends is
|
|
304
|
+
decided when it is used (:meth:`get`): a page that is opened again counts as doing something - not whether
|
|
305
|
+
somebody else logged in meanwhile."""
|
|
306
|
+
now = self._clock()
|
|
307
|
+
for key in [k for k, s in self._sessions.items() if s.expires < now]:
|
|
308
|
+
del self._sessions[key]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class LoginThrottle:
|
|
312
|
+
"""After ``max_failures`` failed logins from one address, every further failed attempt blocks the address for
|
|
313
|
+
``lockout`` seconds. Failures are forgotten ``reset_after`` seconds after the last failure or with a successful
|
|
314
|
+
login.
|
|
315
|
+
|
|
316
|
+
At most ``max_addresses`` addresses are tracked. If there are more, addresses that are not blocked are forgotten
|
|
317
|
+
first (oldest failure first) - flooding the table from many addresses must not unblock an attacked address.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
def __init__(
|
|
321
|
+
self,
|
|
322
|
+
max_failures: int = 5,
|
|
323
|
+
lockout: float = 30,
|
|
324
|
+
reset_after: float = 900,
|
|
325
|
+
clock: Clock = time.monotonic,
|
|
326
|
+
max_addresses: int = 10000,
|
|
327
|
+
) -> None:
|
|
328
|
+
self._max_failures = max_failures
|
|
329
|
+
self._lockout = lockout
|
|
330
|
+
self._reset_after = reset_after
|
|
331
|
+
self._clock = clock
|
|
332
|
+
self._max_addresses = max_addresses
|
|
333
|
+
self._failures: dict[str, list[float]] = {} # address -> [count, time of last failure, blocked until]
|
|
334
|
+
self._lock = threading.Lock()
|
|
335
|
+
|
|
336
|
+
def _entry(self, address: str) -> list[float] | None:
|
|
337
|
+
entry = self._failures.get(address)
|
|
338
|
+
if entry is not None and self._clock() - entry[1] > self._reset_after:
|
|
339
|
+
del self._failures[address]
|
|
340
|
+
entry = None
|
|
341
|
+
return entry
|
|
342
|
+
|
|
343
|
+
def retry_after(self, address: str) -> int:
|
|
344
|
+
"""Seconds until the next login attempt is allowed (0: allowed now)."""
|
|
345
|
+
with self._lock:
|
|
346
|
+
entry = self._entry(address)
|
|
347
|
+
if entry is None:
|
|
348
|
+
return 0
|
|
349
|
+
return max(0, int(entry[2] - self._clock() + 0.999))
|
|
350
|
+
|
|
351
|
+
def failed(self, address: str) -> None:
|
|
352
|
+
with self._lock:
|
|
353
|
+
now = self._clock()
|
|
354
|
+
entry = self._entry(address)
|
|
355
|
+
if entry is None:
|
|
356
|
+
entry = self._failures[address] = [0, now, 0]
|
|
357
|
+
entry[0] += 1
|
|
358
|
+
entry[1] = now
|
|
359
|
+
if entry[0] >= self._max_failures:
|
|
360
|
+
entry[2] = now + self._lockout
|
|
361
|
+
if len(self._failures) > self._max_addresses:
|
|
362
|
+
self._shrink(now)
|
|
363
|
+
|
|
364
|
+
def _shrink(self, now: float) -> None:
|
|
365
|
+
excess = len(self._failures) - self._max_addresses
|
|
366
|
+
# not blocked before blocked, then oldest failure first
|
|
367
|
+
order = sorted(self._failures.items(), key=lambda item: (item[1][2] > now, item[1][1]))
|
|
368
|
+
for address, _ in order[:excess]:
|
|
369
|
+
del self._failures[address]
|
|
370
|
+
|
|
371
|
+
def succeeded(self, address: str) -> None:
|
|
372
|
+
with self._lock:
|
|
373
|
+
self._failures.pop(address, None)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Self signed certificate for the rest api - created with the openssl command line tool on first start."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
|
|
11
|
+
__all__ = ["create_self_signed_certificate", "create_ssl_context", "ensure_certificate", "fingerprint"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_ip(name: str) -> bool:
|
|
15
|
+
for family in (socket.AF_INET, socket.AF_INET6):
|
|
16
|
+
try:
|
|
17
|
+
socket.inet_pton(family, name)
|
|
18
|
+
except OSError:
|
|
19
|
+
continue
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _subject_alt_names(hostnames: Iterable[str] | None) -> str:
|
|
25
|
+
names = ["localhost", socket.gethostname(), socket.gethostname() + ".local", *(hostnames or [])]
|
|
26
|
+
entries: list[str] = []
|
|
27
|
+
for name in [*names, "127.0.0.1"]:
|
|
28
|
+
entry = f"{'IP' if _is_ip(name) else 'DNS'}:{name}"
|
|
29
|
+
if entry not in entries:
|
|
30
|
+
entries.append(entry)
|
|
31
|
+
return ",".join(entries)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _create_private_file(filename: str) -> None:
|
|
35
|
+
"""Empty file with mode 600 - openssl writes the key into it, so the key is never readable by others.
|
|
36
|
+
|
|
37
|
+
(The previous version changed the umask of the whole process, which affected files created by other threads.)
|
|
38
|
+
"""
|
|
39
|
+
if os.path.exists(filename):
|
|
40
|
+
os.chmod(filename, 0o600)
|
|
41
|
+
else:
|
|
42
|
+
os.close(os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_self_signed_certificate(
|
|
46
|
+
certificate_file: str, key_file: str, hostnames: Iterable[str] | None = None, days: int = 3650
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Create an ec (prime256v1) key and a self signed certificate for this host (valid for ``days`` days).
|
|
49
|
+
|
|
50
|
+
:raises RuntimeError: if openssl is missing or fails.
|
|
51
|
+
"""
|
|
52
|
+
for filename in (certificate_file, key_file):
|
|
53
|
+
os.makedirs(os.path.dirname(os.path.abspath(filename)), mode=0o700, exist_ok=True)
|
|
54
|
+
_create_private_file(key_file)
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[
|
|
58
|
+
"openssl",
|
|
59
|
+
"req",
|
|
60
|
+
"-x509",
|
|
61
|
+
"-newkey",
|
|
62
|
+
"ec",
|
|
63
|
+
"-pkeyopt",
|
|
64
|
+
"ec_paramgen_curve:prime256v1",
|
|
65
|
+
"-nodes",
|
|
66
|
+
"-sha256",
|
|
67
|
+
"-days",
|
|
68
|
+
str(days),
|
|
69
|
+
"-subj",
|
|
70
|
+
f"/CN={socket.gethostname()}/O=Tiny Jukebox",
|
|
71
|
+
"-addext",
|
|
72
|
+
f"subjectAltName={_subject_alt_names(hostnames)}",
|
|
73
|
+
"-keyout",
|
|
74
|
+
key_file,
|
|
75
|
+
"-out",
|
|
76
|
+
certificate_file,
|
|
77
|
+
],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
check=False,
|
|
81
|
+
)
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
raise RuntimeError("openssl not found - install openssl or provide certificate and private key.") from None
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
raise RuntimeError(f"creating the certificate failed: {result.stderr.strip()}")
|
|
86
|
+
os.chmod(key_file, 0o600)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fingerprint(certificate_file: str) -> str:
|
|
90
|
+
"""SHA-256 fingerprint as shown by browsers (AA:BB:...)."""
|
|
91
|
+
with open(certificate_file, encoding="ascii") as f:
|
|
92
|
+
der = ssl.PEM_cert_to_DER_cert(f.read())
|
|
93
|
+
digest = hashlib.sha256(der).hexdigest().upper()
|
|
94
|
+
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ensure_certificate(
|
|
98
|
+
certificate_file: str, key_file: str, logger: logging.Logger, hostnames: Iterable[str] | None = None
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Create certificate and key if one of them is missing. Returns the fingerprint of the certificate."""
|
|
101
|
+
if not (os.path.exists(certificate_file) and os.path.exists(key_file)):
|
|
102
|
+
logger.info("creating self signed certificate '%s'.", certificate_file)
|
|
103
|
+
create_self_signed_certificate(certificate_file, key_file, hostnames)
|
|
104
|
+
return fingerprint(certificate_file)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def create_ssl_context(certificate_file: str, key_file: str) -> ssl.SSLContext:
|
|
108
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
109
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
110
|
+
context.load_cert_chain(certificate_file, key_file)
|
|
111
|
+
return context
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Keeps the latest log records in memory - shown on the web page."""
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import itertools
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ["LEVELS", "LogBuffer"]
|
|
10
|
+
|
|
11
|
+
LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LogBuffer(logging.Handler):
|
|
15
|
+
"""Log handler with the newest ``capacity`` records; every record gets an increasing id."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, capacity: int = 5000) -> None:
|
|
18
|
+
super().__init__(logging.DEBUG)
|
|
19
|
+
self._records: collections.deque[dict[str, Any]] = collections.deque(maxlen=capacity)
|
|
20
|
+
self._counter = itertools.count(1)
|
|
21
|
+
self._lock_records = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
24
|
+
try:
|
|
25
|
+
message = record.getMessage()
|
|
26
|
+
if record.exc_info:
|
|
27
|
+
message += "\n" + logging.Formatter().formatException(record.exc_info)
|
|
28
|
+
except Exception: # a broken log call must not break the logging
|
|
29
|
+
message = str(record.msg)
|
|
30
|
+
with self._lock_records:
|
|
31
|
+
self._records.append(
|
|
32
|
+
{
|
|
33
|
+
"id": next(self._counter),
|
|
34
|
+
"time": record.created,
|
|
35
|
+
"level": record.levelname,
|
|
36
|
+
"logger": record.name,
|
|
37
|
+
"thread": record.threadName,
|
|
38
|
+
"message": message,
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def last_id(self) -> int:
|
|
44
|
+
"""Id of the newest record (0: none yet)."""
|
|
45
|
+
with self._lock_records:
|
|
46
|
+
return self._records[-1]["id"] if self._records else 0
|
|
47
|
+
|
|
48
|
+
def get(self, after: int = 0, level: str = "DEBUG", text: str | None = None, limit: int = 500) -> dict[str, Any]:
|
|
49
|
+
"""Records with id > after, at least ``level``, containing ``text`` (case insensitive) - the newest
|
|
50
|
+
``limit``."""
|
|
51
|
+
minimum = LEVELS.index(level) if level in LEVELS else 0
|
|
52
|
+
needle = text.lower() if text else None
|
|
53
|
+
with self._lock_records:
|
|
54
|
+
records = list(self._records)
|
|
55
|
+
result = [
|
|
56
|
+
r
|
|
57
|
+
for r in records
|
|
58
|
+
if r["id"] > after
|
|
59
|
+
and (r["level"] not in LEVELS or LEVELS.index(r["level"]) >= minimum)
|
|
60
|
+
and (needle is None or needle in r["message"].lower() or needle in r["logger"].lower())
|
|
61
|
+
]
|
|
62
|
+
return {"entries": result[-limit:], "last_id": records[-1]["id"] if records else after}
|