local-control 0.1.2__tar.gz
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.
- local_control-0.1.2/LICENSE +21 -0
- local_control-0.1.2/PKG-INFO +49 -0
- local_control-0.1.2/README.md +36 -0
- local_control-0.1.2/local_control/__init__.py +11 -0
- local_control-0.1.2/local_control/app.py +291 -0
- local_control-0.1.2/local_control/auth.py +240 -0
- local_control-0.1.2/local_control/cli.py +143 -0
- local_control-0.1.2/local_control/clipboard.py +342 -0
- local_control-0.1.2/local_control/config.py +47 -0
- local_control-0.1.2/local_control/control.py +1043 -0
- local_control-0.1.2/local_control/startup.py +140 -0
- local_control-0.1.2/local_control/static/css/styles.css +393 -0
- local_control-0.1.2/local_control/static/index.html +140 -0
- local_control-0.1.2/local_control/static/js/app.js +1658 -0
- local_control-0.1.2/local_control/utils/__init__.py +9 -0
- local_control-0.1.2/local_control/utils/qrcodegen.py +907 -0
- local_control-0.1.2/local_control/utils/terminal_qr.py +34 -0
- local_control-0.1.2/local_control.egg-info/PKG-INFO +49 -0
- local_control-0.1.2/local_control.egg-info/SOURCES.txt +38 -0
- local_control-0.1.2/local_control.egg-info/dependency_links.txt +1 -0
- local_control-0.1.2/local_control.egg-info/entry_points.txt +2 -0
- local_control-0.1.2/local_control.egg-info/requires.txt +1 -0
- local_control-0.1.2/local_control.egg-info/top_level.txt +1 -0
- local_control-0.1.2/pyproject.toml +23 -0
- local_control-0.1.2/setup.cfg +19 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 DIYer22
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: local_control
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: LAN-accessible remote control server for mouse, keyboard, and power management
|
|
5
|
+
Author: DIYer22
|
|
6
|
+
License: MIT License
|
|
7
|
+
Project-URL: Homepage, https://github.com/DIYer22/local_control
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: flask>=2.3
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# 🖱️ Local Control
|
|
15
|
+
|
|
16
|
+
Let you steer the computer's mouse, keyboard from any device's browser.
|
|
17
|
+
|
|
18
|
+
<a href="https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg">
|
|
19
|
+
<img style="height:384px" src=https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg>
|
|
20
|
+
</a>
|
|
21
|
+
|
|
22
|
+
The server is written in pure Python with minimal dependencies and ships with a mobile-friendly frontend.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
- Mouse cursor movement and click controls from touch or mouse devices.
|
|
26
|
+
- Realtime input field streams keystrokes (including Backspace/Delete) as you type.
|
|
27
|
+
- OS-level lock, and shutdown shortcuts (best-effort across Windows, macOS, Linux).
|
|
28
|
+
- Authentication that reuses the current OS account credentials, remembers trusted devices, and rate-limits brute-force attempts.
|
|
29
|
+
- Build with GPT-5-Codex, easy to customize and modify with vibe coding.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
- Python 3.9 or newer.
|
|
33
|
+
- Desktop environments capable of receiving simulated input (X11/Wayland, Windows, or macOS).
|
|
34
|
+
- Linux/X11 hosts require the `libX11` and `libXtst` system libraries (commonly present on desktop distributions; Wayland sessions need XWayland support).
|
|
35
|
+
- macOS hosts must grant the Python process accessibility permissions (System Settings → Privacy & Security → Accessibility).
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
```bash
|
|
39
|
+
pip install local_control
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
```bash
|
|
44
|
+
local-control --help
|
|
45
|
+
local-control --port 4001
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Open `http://<host-ip>:4001` from your phone, tablet, or another computer on the same LAN. Sign in with the current desktop user's username and password. Devices marked as trusted skip future logins under the same secret.
|
|
49
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# 🖱️ Local Control
|
|
2
|
+
|
|
3
|
+
Let you steer the computer's mouse, keyboard from any device's browser.
|
|
4
|
+
|
|
5
|
+
<a href="https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg">
|
|
6
|
+
<img style="height:384px" src=https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg>
|
|
7
|
+
</a>
|
|
8
|
+
|
|
9
|
+
The server is written in pure Python with minimal dependencies and ships with a mobile-friendly frontend.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
- Mouse cursor movement and click controls from touch or mouse devices.
|
|
13
|
+
- Realtime input field streams keystrokes (including Backspace/Delete) as you type.
|
|
14
|
+
- OS-level lock, and shutdown shortcuts (best-effort across Windows, macOS, Linux).
|
|
15
|
+
- Authentication that reuses the current OS account credentials, remembers trusted devices, and rate-limits brute-force attempts.
|
|
16
|
+
- Build with GPT-5-Codex, easy to customize and modify with vibe coding.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
- Python 3.9 or newer.
|
|
20
|
+
- Desktop environments capable of receiving simulated input (X11/Wayland, Windows, or macOS).
|
|
21
|
+
- Linux/X11 hosts require the `libX11` and `libXtst` system libraries (commonly present on desktop distributions; Wayland sessions need XWayland support).
|
|
22
|
+
- macOS hosts must grant the Python process accessibility permissions (System Settings → Privacy & Security → Accessibility).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
```bash
|
|
26
|
+
pip install local_control
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
```bash
|
|
31
|
+
local-control --help
|
|
32
|
+
local-control --port 4001
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Open `http://<host-ip>:4001` from your phone, tablet, or another computer on the same LAN. Sign in with the current desktop user's username and password. Devices marked as trusted skip future logins under the same secret.
|
|
36
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Control package exposing the web server and CLI helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__all__ = ["create_app", "__version__"]
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from .app import create_app # noqa: E402 (lazy import to avoid side effects)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask application wiring the authentication manager, control handlers,
|
|
3
|
+
and static frontend.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from flask import (
|
|
14
|
+
Flask,
|
|
15
|
+
Response,
|
|
16
|
+
jsonify,
|
|
17
|
+
make_response,
|
|
18
|
+
request,
|
|
19
|
+
send_from_directory,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .auth import AuthManager, CredentialError, RateLimitError
|
|
23
|
+
from . import control, clipboard
|
|
24
|
+
from .clipboard import ClipboardData
|
|
25
|
+
|
|
26
|
+
SESSION_COOKIE = "session_token"
|
|
27
|
+
TRUSTED_COOKIE = "trusted_token"
|
|
28
|
+
SESSION_MAX_AGE = 60 * 60 * 4 # 4 hours
|
|
29
|
+
TRUSTED_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
|
30
|
+
|
|
31
|
+
LOG = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_app(auth_manager: Optional[AuthManager] = None) -> Flask:
|
|
35
|
+
static_dir = Path(__file__).parent / "static"
|
|
36
|
+
app = Flask(
|
|
37
|
+
__name__,
|
|
38
|
+
static_folder=str(static_dir),
|
|
39
|
+
static_url_path="/static",
|
|
40
|
+
)
|
|
41
|
+
app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
|
|
42
|
+
auth = auth_manager or AuthManager()
|
|
43
|
+
|
|
44
|
+
def client_key() -> str:
|
|
45
|
+
return request.remote_addr or "unknown"
|
|
46
|
+
|
|
47
|
+
def ensure_session() -> Tuple[Optional[str], Optional[str]]:
|
|
48
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
49
|
+
user = auth.session_user(token)
|
|
50
|
+
if user:
|
|
51
|
+
return user, None
|
|
52
|
+
|
|
53
|
+
trusted = request.cookies.get(TRUSTED_COOKIE)
|
|
54
|
+
user = auth.auto_login(trusted)
|
|
55
|
+
if user:
|
|
56
|
+
fresh = auth.create_session(user)
|
|
57
|
+
return user, fresh
|
|
58
|
+
return None, None
|
|
59
|
+
|
|
60
|
+
def require_auth() -> Tuple[Optional[str], Optional[str]]:
|
|
61
|
+
user, new_token = ensure_session()
|
|
62
|
+
if not user:
|
|
63
|
+
return None, None
|
|
64
|
+
return user, new_token
|
|
65
|
+
|
|
66
|
+
def build_response(
|
|
67
|
+
payload: Dict[str, Any],
|
|
68
|
+
session_token: Optional[str] = None,
|
|
69
|
+
clear_session: bool = False,
|
|
70
|
+
trusted_token: Optional[str] = None,
|
|
71
|
+
clear_trusted: bool = False,
|
|
72
|
+
status: int = 200,
|
|
73
|
+
) -> Response:
|
|
74
|
+
response = make_response(jsonify(payload), status)
|
|
75
|
+
if session_token:
|
|
76
|
+
response.set_cookie(
|
|
77
|
+
SESSION_COOKIE,
|
|
78
|
+
session_token,
|
|
79
|
+
httponly=True,
|
|
80
|
+
secure=False,
|
|
81
|
+
samesite="Strict",
|
|
82
|
+
max_age=SESSION_MAX_AGE,
|
|
83
|
+
)
|
|
84
|
+
if clear_session:
|
|
85
|
+
response.delete_cookie(SESSION_COOKIE)
|
|
86
|
+
if trusted_token:
|
|
87
|
+
response.set_cookie(
|
|
88
|
+
TRUSTED_COOKIE,
|
|
89
|
+
trusted_token,
|
|
90
|
+
httponly=True,
|
|
91
|
+
secure=False,
|
|
92
|
+
samesite="Strict",
|
|
93
|
+
max_age=TRUSTED_MAX_AGE,
|
|
94
|
+
)
|
|
95
|
+
if clear_trusted:
|
|
96
|
+
response.delete_cookie(TRUSTED_COOKIE)
|
|
97
|
+
return response
|
|
98
|
+
|
|
99
|
+
# Routes -----------------------------------------------------------------
|
|
100
|
+
@app.get("/")
|
|
101
|
+
def index() -> Response:
|
|
102
|
+
return send_from_directory(app.static_folder, "index.html")
|
|
103
|
+
|
|
104
|
+
@app.get("/api/session")
|
|
105
|
+
def session_info() -> Response:
|
|
106
|
+
user, fresh_token = ensure_session()
|
|
107
|
+
if user:
|
|
108
|
+
return build_response(
|
|
109
|
+
{"authenticated": True, "username": user},
|
|
110
|
+
session_token=fresh_token,
|
|
111
|
+
)
|
|
112
|
+
return build_response({"authenticated": False, "username": None})
|
|
113
|
+
|
|
114
|
+
@app.post("/api/login")
|
|
115
|
+
def login() -> Response:
|
|
116
|
+
data = request.get_json(silent=True) or {}
|
|
117
|
+
username = str(data.get("username", "")).strip()
|
|
118
|
+
password = str(data.get("password", ""))
|
|
119
|
+
remember = bool(data.get("remember", False))
|
|
120
|
+
remote = client_key()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
auth.check_rate_limit(remote)
|
|
124
|
+
except RateLimitError as exc:
|
|
125
|
+
return build_response({"error": str(exc)}, status=429)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
verified = auth.verify_credentials(username, password)
|
|
129
|
+
except CredentialError as exc:
|
|
130
|
+
LOG.warning("Credential verification error: %s", exc)
|
|
131
|
+
return build_response({"error": str(exc)}, status=400)
|
|
132
|
+
|
|
133
|
+
if not verified:
|
|
134
|
+
auth.register_failure(remote)
|
|
135
|
+
return build_response({"error": "Invalid username or password."}, status=401)
|
|
136
|
+
|
|
137
|
+
auth.register_success(remote)
|
|
138
|
+
session_token = auth.create_session(username)
|
|
139
|
+
trusted_token = auth.create_trusted_token(username) if remember else None
|
|
140
|
+
|
|
141
|
+
payload = {"authenticated": True, "username": username}
|
|
142
|
+
return build_response(
|
|
143
|
+
payload,
|
|
144
|
+
session_token=session_token,
|
|
145
|
+
trusted_token=trusted_token,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@app.post("/api/logout")
|
|
149
|
+
def logout() -> Response:
|
|
150
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
151
|
+
if token:
|
|
152
|
+
auth.destroy_session(token)
|
|
153
|
+
return build_response({"authenticated": False}, clear_session=True)
|
|
154
|
+
|
|
155
|
+
def auth_endpoint(handler: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Response:
|
|
156
|
+
user, fresh_token = require_auth()
|
|
157
|
+
if not user:
|
|
158
|
+
return build_response({"error": "Authentication required."}, status=401)
|
|
159
|
+
payload = request.get_json(silent=True) or {}
|
|
160
|
+
try:
|
|
161
|
+
response_payload = handler(payload)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
return build_response({"error": str(exc)}, status=400)
|
|
164
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
165
|
+
LOG.exception("Handler error: %s", exc)
|
|
166
|
+
return build_response({"error": str(exc)}, status=500)
|
|
167
|
+
return build_response(response_payload, session_token=fresh_token)
|
|
168
|
+
|
|
169
|
+
@app.post("/api/mouse/move")
|
|
170
|
+
def mouse_move() -> Response:
|
|
171
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
172
|
+
dx = float(data.get("dx", 0.0))
|
|
173
|
+
dy = float(data.get("dy", 0.0))
|
|
174
|
+
state = control.move_cursor(dx, dy)
|
|
175
|
+
return {"status": "ok", "state": state}
|
|
176
|
+
|
|
177
|
+
return auth_endpoint(action)
|
|
178
|
+
|
|
179
|
+
@app.post("/api/mouse/click")
|
|
180
|
+
def mouse_click() -> Response:
|
|
181
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
182
|
+
button = str(data.get("button", "left"))
|
|
183
|
+
double = bool(data.get("double", False))
|
|
184
|
+
control.click(button=button, double=double)
|
|
185
|
+
return {"status": "ok"}
|
|
186
|
+
|
|
187
|
+
return auth_endpoint(action)
|
|
188
|
+
|
|
189
|
+
@app.post("/api/mouse/button")
|
|
190
|
+
def mouse_button() -> Response:
|
|
191
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
192
|
+
button = str(data.get("button", "left"))
|
|
193
|
+
button_action = str(data.get("action", "down"))
|
|
194
|
+
control.button_action(button=button, action=button_action)
|
|
195
|
+
return {"status": "ok"}
|
|
196
|
+
|
|
197
|
+
return auth_endpoint(action)
|
|
198
|
+
|
|
199
|
+
@app.post("/api/mouse/scroll")
|
|
200
|
+
def mouse_scroll() -> Response:
|
|
201
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
202
|
+
vertical = float(data.get("vertical", 0.0))
|
|
203
|
+
horizontal = float(data.get("horizontal", 0.0))
|
|
204
|
+
control.scroll(vertical=vertical, horizontal=horizontal)
|
|
205
|
+
return {"status": "ok"}
|
|
206
|
+
|
|
207
|
+
return auth_endpoint(action)
|
|
208
|
+
|
|
209
|
+
@app.get("/api/mouse/state")
|
|
210
|
+
def mouse_state() -> Response:
|
|
211
|
+
user, fresh_token = require_auth()
|
|
212
|
+
if not user:
|
|
213
|
+
return build_response({"error": "Authentication required."}, status=401)
|
|
214
|
+
state = control.cursor_state()
|
|
215
|
+
return build_response({"status": "ok", "state": state}, session_token=fresh_token)
|
|
216
|
+
|
|
217
|
+
@app.post("/api/keyboard/type")
|
|
218
|
+
def keyboard_type() -> Response:
|
|
219
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
220
|
+
text = str(data.get("text", ""))
|
|
221
|
+
control.type_text(text)
|
|
222
|
+
return {"status": "ok"}
|
|
223
|
+
|
|
224
|
+
return auth_endpoint(action)
|
|
225
|
+
|
|
226
|
+
@app.post("/api/keyboard/key")
|
|
227
|
+
def keyboard_key() -> Response:
|
|
228
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
229
|
+
key = str(data.get("key", "")).lower()
|
|
230
|
+
action_name = str(data.get("action", "press")).lower()
|
|
231
|
+
control.key_action(key, action_name)
|
|
232
|
+
return {"status": "ok"}
|
|
233
|
+
|
|
234
|
+
return auth_endpoint(action)
|
|
235
|
+
|
|
236
|
+
@app.post("/api/system/lock")
|
|
237
|
+
def system_lock() -> Response:
|
|
238
|
+
def action(_: Dict[str, Any]) -> Dict[str, Any]:
|
|
239
|
+
control.lock_screen()
|
|
240
|
+
return {"status": "ok"}
|
|
241
|
+
|
|
242
|
+
return auth_endpoint(action)
|
|
243
|
+
|
|
244
|
+
@app.post("/api/system/unlock")
|
|
245
|
+
def system_unlock() -> Response:
|
|
246
|
+
def action(_: Dict[str, Any]) -> Dict[str, Any]:
|
|
247
|
+
control.unlock_screen()
|
|
248
|
+
return {"status": "ok"}
|
|
249
|
+
|
|
250
|
+
return auth_endpoint(action)
|
|
251
|
+
|
|
252
|
+
@app.post("/api/system/shutdown")
|
|
253
|
+
def system_shutdown() -> Response:
|
|
254
|
+
def action(_: Dict[str, Any]) -> Dict[str, Any]:
|
|
255
|
+
control.shutdown_system()
|
|
256
|
+
return {"status": "ok"}
|
|
257
|
+
|
|
258
|
+
return auth_endpoint(action)
|
|
259
|
+
|
|
260
|
+
@app.get("/api/clipboard")
|
|
261
|
+
def clipboard_read() -> Response:
|
|
262
|
+
user, fresh_token = require_auth()
|
|
263
|
+
if not user:
|
|
264
|
+
return build_response({"error": "Authentication required."}, status=401)
|
|
265
|
+
clip = clipboard.get_clipboard()
|
|
266
|
+
if clip:
|
|
267
|
+
content = {"type": clip.kind, "data": clip.data, "mime": clip.mime}
|
|
268
|
+
else:
|
|
269
|
+
content = None
|
|
270
|
+
return build_response(
|
|
271
|
+
{"status": "ok", "content": content},
|
|
272
|
+
session_token=fresh_token,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@app.post("/api/clipboard")
|
|
276
|
+
def clipboard_write() -> Response:
|
|
277
|
+
def action(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
278
|
+
clip_type = str(data.get("type", "")).lower()
|
|
279
|
+
if clip_type not in {"text", "image"}:
|
|
280
|
+
raise ValueError("Clipboard type must be 'text' or 'image'.")
|
|
281
|
+
payload = data.get("data")
|
|
282
|
+
if payload is None:
|
|
283
|
+
raise ValueError("Clipboard payload missing.")
|
|
284
|
+
mime = data.get("mime")
|
|
285
|
+
clip = ClipboardData(kind=clip_type, data=str(payload), mime=str(mime) if mime else None)
|
|
286
|
+
clipboard.set_clipboard(clip)
|
|
287
|
+
return {"status": "ok"}
|
|
288
|
+
|
|
289
|
+
return auth_endpoint(action)
|
|
290
|
+
|
|
291
|
+
return app
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication manager handling OS credential checks, sessions, device trust,
|
|
3
|
+
and brute-force protections.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import getpass
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from .config import data_dir, load_json, save_json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimitError(Exception):
|
|
22
|
+
"""Raised when a client exceeds the allowed login attempts."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CredentialError(Exception):
|
|
26
|
+
"""Raised when provided credentials cannot be validated."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Session:
|
|
31
|
+
username: str
|
|
32
|
+
created_at: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TrustedDevice:
|
|
37
|
+
username: str
|
|
38
|
+
token_hash: str
|
|
39
|
+
created_at: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthManager:
|
|
43
|
+
MAX_ATTEMPTS = 5
|
|
44
|
+
WINDOW_SECONDS = 600
|
|
45
|
+
LOCKOUT_SECONDS = 600
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
base = data_dir()
|
|
49
|
+
self._secret_path = base / "secret.key"
|
|
50
|
+
self._trusted_path = base / "trusted_devices.json"
|
|
51
|
+
self._secret = self._load_secret()
|
|
52
|
+
self._sessions: Dict[str, Session] = {}
|
|
53
|
+
self._trusted_devices: List[TrustedDevice] = self._load_trusted_devices()
|
|
54
|
+
self._attempts: Dict[str, Dict[str, float]] = {}
|
|
55
|
+
self._current_user = getpass.getuser()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def current_user(self) -> str:
|
|
59
|
+
return self._current_user
|
|
60
|
+
|
|
61
|
+
# Secret helpers ---------------------------------------------------------
|
|
62
|
+
def _load_secret(self) -> bytes:
|
|
63
|
+
if self._secret_path.exists():
|
|
64
|
+
return self._secret_path.read_bytes()
|
|
65
|
+
|
|
66
|
+
secret = secrets.token_bytes(32)
|
|
67
|
+
self._secret_path.write_bytes(secret)
|
|
68
|
+
return secret
|
|
69
|
+
|
|
70
|
+
# Trusted device persistence ---------------------------------------------
|
|
71
|
+
def _load_trusted_devices(self) -> List[TrustedDevice]:
|
|
72
|
+
data = load_json(
|
|
73
|
+
self._trusted_path,
|
|
74
|
+
default={"devices": []},
|
|
75
|
+
)
|
|
76
|
+
devices: List[TrustedDevice] = []
|
|
77
|
+
for entry in data.get("devices", []):
|
|
78
|
+
try:
|
|
79
|
+
devices.append(
|
|
80
|
+
TrustedDevice(
|
|
81
|
+
username=entry["username"],
|
|
82
|
+
token_hash=entry["token_hash"],
|
|
83
|
+
created_at=float(entry.get("created_at", 0.0)),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
except KeyError:
|
|
87
|
+
continue
|
|
88
|
+
return devices
|
|
89
|
+
|
|
90
|
+
def _persist_trusted(self) -> None:
|
|
91
|
+
payload = {
|
|
92
|
+
"devices": [
|
|
93
|
+
{
|
|
94
|
+
"username": device.username,
|
|
95
|
+
"token_hash": device.token_hash,
|
|
96
|
+
"created_at": device.created_at,
|
|
97
|
+
}
|
|
98
|
+
for device in self._trusted_devices
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
save_json(self._trusted_path, payload)
|
|
102
|
+
|
|
103
|
+
# Rate limiting ----------------------------------------------------------
|
|
104
|
+
def check_rate_limit(self, key: str) -> None:
|
|
105
|
+
entry = self._attempts.get(key)
|
|
106
|
+
now = time.time()
|
|
107
|
+
if not entry:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
locked_until = entry.get("locked_until", 0.0)
|
|
111
|
+
if locked_until and locked_until > now:
|
|
112
|
+
raise RateLimitError("Too many attempts. Retry later.")
|
|
113
|
+
|
|
114
|
+
timestamps = entry.get("timestamps", [])
|
|
115
|
+
# Drop attempts outside of the time window.
|
|
116
|
+
timestamps = [ts for ts in timestamps if now - ts < self.WINDOW_SECONDS]
|
|
117
|
+
entry["timestamps"] = timestamps
|
|
118
|
+
if len(timestamps) >= self.MAX_ATTEMPTS:
|
|
119
|
+
entry["locked_until"] = now + self.LOCKOUT_SECONDS
|
|
120
|
+
raise RateLimitError("Too many attempts. Retry later.")
|
|
121
|
+
|
|
122
|
+
def register_failure(self, key: str) -> None:
|
|
123
|
+
now = time.time()
|
|
124
|
+
entry = self._attempts.setdefault(
|
|
125
|
+
key, {"timestamps": [], "locked_until": 0.0}
|
|
126
|
+
)
|
|
127
|
+
entry.setdefault("timestamps", []).append(now)
|
|
128
|
+
# Enforce the window retention.
|
|
129
|
+
entry["timestamps"] = [
|
|
130
|
+
ts for ts in entry["timestamps"] if now - ts < self.WINDOW_SECONDS
|
|
131
|
+
]
|
|
132
|
+
if len(entry["timestamps"]) >= self.MAX_ATTEMPTS:
|
|
133
|
+
entry["locked_until"] = now + self.LOCKOUT_SECONDS
|
|
134
|
+
|
|
135
|
+
def register_success(self, key: str) -> None:
|
|
136
|
+
if key in self._attempts:
|
|
137
|
+
del self._attempts[key]
|
|
138
|
+
|
|
139
|
+
# Token helpers ----------------------------------------------------------
|
|
140
|
+
def _hash_token(self, token: str) -> str:
|
|
141
|
+
return hashlib.sha256(self._secret + token.encode("utf-8")).hexdigest()
|
|
142
|
+
|
|
143
|
+
def create_session(self, username: str) -> str:
|
|
144
|
+
token = secrets.token_urlsafe(32)
|
|
145
|
+
token_hash = self._hash_token(token)
|
|
146
|
+
self._sessions[token_hash] = Session(username=username, created_at=time.time())
|
|
147
|
+
return token
|
|
148
|
+
|
|
149
|
+
def destroy_session(self, token: str) -> None:
|
|
150
|
+
token_hash = self._hash_token(token)
|
|
151
|
+
self._sessions.pop(token_hash, None)
|
|
152
|
+
|
|
153
|
+
def session_user(self, token: Optional[str]) -> Optional[str]:
|
|
154
|
+
if not token:
|
|
155
|
+
return None
|
|
156
|
+
token_hash = self._hash_token(token)
|
|
157
|
+
session = self._sessions.get(token_hash)
|
|
158
|
+
if session:
|
|
159
|
+
return session.username
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Trusted devices --------------------------------------------------------
|
|
163
|
+
def create_trusted_token(self, username: str) -> str:
|
|
164
|
+
token = secrets.token_urlsafe(32)
|
|
165
|
+
token_hash = self._hash_token(token)
|
|
166
|
+
self._trusted_devices.append(
|
|
167
|
+
TrustedDevice(username=username, token_hash=token_hash, created_at=time.time())
|
|
168
|
+
)
|
|
169
|
+
self._persist_trusted()
|
|
170
|
+
return token
|
|
171
|
+
|
|
172
|
+
def auto_login(self, token: Optional[str]) -> Optional[str]:
|
|
173
|
+
if not token:
|
|
174
|
+
return None
|
|
175
|
+
token_hash = self._hash_token(token)
|
|
176
|
+
for device in self._trusted_devices:
|
|
177
|
+
if device.token_hash == token_hash and device.username == self._current_user:
|
|
178
|
+
return device.username
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# Credential verification ------------------------------------------------
|
|
182
|
+
def verify_credentials(self, username: str, password: str) -> bool:
|
|
183
|
+
if username != self._current_user:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
system = sys.platform
|
|
187
|
+
if system.startswith("win"):
|
|
188
|
+
return self._verify_windows(username, password)
|
|
189
|
+
return self._verify_unix(username, password)
|
|
190
|
+
|
|
191
|
+
def _verify_unix(self, username: str, password: str) -> bool:
|
|
192
|
+
# Use sudo in non-interactive mode. Requires the user to be part of sudoers.
|
|
193
|
+
if not password:
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
subprocess.run(
|
|
197
|
+
["sudo", "-k"],
|
|
198
|
+
stdout=subprocess.DEVNULL,
|
|
199
|
+
stderr=subprocess.DEVNULL,
|
|
200
|
+
check=False,
|
|
201
|
+
)
|
|
202
|
+
proc = subprocess.run(
|
|
203
|
+
["sudo", "-S", "-p", "", "true"],
|
|
204
|
+
input=f"{password}\n".encode("utf-8"),
|
|
205
|
+
stdout=subprocess.DEVNULL,
|
|
206
|
+
stderr=subprocess.PIPE,
|
|
207
|
+
check=False,
|
|
208
|
+
timeout=10,
|
|
209
|
+
)
|
|
210
|
+
return proc.returncode == 0
|
|
211
|
+
except (OSError, subprocess.SubprocessError):
|
|
212
|
+
raise CredentialError(
|
|
213
|
+
"Could not verify credentials on this platform. Ensure sudo is available."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _verify_windows(self, username: str, password: str) -> bool:
|
|
217
|
+
if not password:
|
|
218
|
+
return False
|
|
219
|
+
try:
|
|
220
|
+
import ctypes
|
|
221
|
+
from ctypes import wintypes
|
|
222
|
+
except ImportError as exc: # pragma: no cover
|
|
223
|
+
raise CredentialError("Windows credential APIs are unavailable.") from exc
|
|
224
|
+
|
|
225
|
+
LOGON32_LOGON_INTERACTIVE = 2
|
|
226
|
+
LOGON32_PROVIDER_DEFAULT = 0
|
|
227
|
+
|
|
228
|
+
handle = wintypes.HANDLE()
|
|
229
|
+
result = ctypes.windll.advapi32.LogonUserW(
|
|
230
|
+
ctypes.c_wchar_p(username),
|
|
231
|
+
ctypes.c_wchar_p(None),
|
|
232
|
+
ctypes.c_wchar_p(password),
|
|
233
|
+
LOGON32_LOGON_INTERACTIVE,
|
|
234
|
+
LOGON32_PROVIDER_DEFAULT,
|
|
235
|
+
ctypes.byref(handle),
|
|
236
|
+
)
|
|
237
|
+
if result:
|
|
238
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
239
|
+
return True
|
|
240
|
+
return False
|