Files
netdesk/tools/patch_accounts.py

220 lines
10 KiB
Python

#!/usr/bin/env python3
"""One-shot patch: add user accounts + email auth to netdesk_server.py."""
import re
p = "server/netdesk_server.py"
s = open(p, encoding="utf-8").read()
ANCHOR = "TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket\nTUNNEL_TID = [1]"
assert ANCHOR in s, "tunnel anchor missing"
BLOCK = '''
# ---------------------------------------------------------------- user accounts (email sign-in for viewers)
import sqlite3
import smtplib
from email.message import EmailMessage as _MailMsg
ACCOUNTS_DB = os.path.join(_server_root, "accounts.db")
USER_SESSIONS = {} # token -> email
def _acct_db():
conn = sqlite3.connect(ACCOUNTS_DB)
conn.execute(
"CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT,"
" email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL,"
" verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT,"
" reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)")
return conn
def _pbkdf(password, salt):
import hashlib as _h
return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex()
def _mail_html(title, body_html, link=None, link_text=None):
link_html = ""
if link:
link_html = (
'<p style="margin:18px 0">'
'<a href="' + link + '" style="background:#3d7eff;color:#fff;padding:11px 22px;'
'border-radius:8px;text-decoration:none;font-weight:600">' + link_text + "</a></p>"
'<p style="color:#8b95a9;font-size:12px">Or paste this link: ' + link + "</p>")
logo = ('<span style="background:#3d7eff;border-radius:8px;width:34px;height:34px;'
'display:inline-flex;align-items:center;justify-content:center">'
'<svg width="20" height="20" viewBox="0 0 32 32">'
'<path d="M8 20l8-12 8 12-8-4z" fill="#fff"/>'
'<rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg></span>')
return (
'<div style="font-family:Segoe UI,Arial,sans-serif;max-width:520px;margin:0 auto;'
'background:#11151f;color:#e8edf6;border:1px solid #222b3d;border-radius:14px;padding:26px">'
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">' + logo +
'<b style="font-size:19px">NetDesk</b></div>'
'<h2 style="margin:0 0 10px;font-size:17px">' + title + "</h2>"
'<div style="font-size:14px;line-height:1.6;color:#c7cfe0">' + body_html + "</div>"
+ link_html +
'<p style="color:#5d6777;font-size:11.5px;margin-top:22px">'
"If you did not request this, ignore this email.</p></div>")
def send_mail(to, subject, html):
if not ARGS.mail_from:
raise RuntimeError("email not configured on this server")
msg = _MailMsg()
msg["From"] = "NetDesk <" + ARGS.mail_from + ">"
msg["To"] = to
msg["Subject"] = subject
msg.set_content("Your mail client does not support HTML.")
msg.add_alternative(html, subtype="html")
host = ARGS.smtp_host or "127.0.0.1"
with smtplib.SMTP(host, 25, timeout=20) as smtp:
smtp.send_message(msg)
def user_from_cookie(req):
if not ARGS.mail_from:
return None
tok = req.cookies.get("nd_user", "")
return USER_SESSIONS.get(tok)
async def auth_signup(req):
body = await req.json()
email = str(body.get("email", "")).strip().lower()
pw = str(body.get("password", ""))
if "@" not in email or len(email) < 5:
return web.json_response({"error": "enter a valid email"}, status=400)
if len(pw) < 8:
return web.json_response({"error": "password needs 8+ characters"}, status=400)
salt = _secrets.token_bytes(16)
vtok = _secrets.token_urlsafe(24)
now = int(time.time())
with _acct_db() as conn:
try:
conn.execute(
"INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) "
"VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now))
except sqlite3.IntegrityError:
row = conn.execute("SELECT verified FROM users WHERE email=?", (email,)).fetchone()
return web.json_response(
{"error": "email already registered - sign in or reset password",
"unverified": bool(row and not row[0])}, status=409)
link = ARGS.public_url + "/api/auth/verify?token=" + vtok
send_mail(email, "Verify your NetDesk account",
_mail_html("Confirm your email",
"Welcome to NetDesk. Click below to activate your account "
"and start connecting to your computers.", link, "Verify my email"))
log_event("auth", "signup: " + email)
return web.json_response({"ok": True, "msg": "verification email sent"})
async def auth_verify(req):
tok = req.query.get("token", "")
with _acct_db() as conn:
row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone()
if not row:
return web.Response(status=400, text="invalid or used link")
conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],))
log_event("auth", "email verified")
raise web.HTTPFound(ARGS.public_url + "/?verified=1")
async def auth_login(req):
body = await req.json()
email = str(body.get("email", "")).strip().lower()
pw = str(body.get("password", ""))
with _acct_db() as conn:
row = conn.execute("SELECT pw_salt,pw_hash,verified FROM users WHERE email=?", (email,)).fetchone()
if not row:
return web.json_response({"error": "wrong email or password"}, status=401)
if _pbkdf(pw, bytes.fromhex(row[0])) != row[1]:
return web.json_response({"error": "wrong email or password"}, status=401)
if not row[2]:
return web.json_response({"error": "check your inbox - email not verified yet"}, status=403)
tok = _secrets.token_urlsafe(24)
USER_SESSIONS[tok] = email
resp = web.json_response({"ok": True, "email": email})
resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=30 * 86400)
log_event("auth", "viewer signed in: " + email)
return resp
async def auth_me(req):
email = user_from_cookie(req)
if not email:
return web.json_response({"error": "not signed in"}, status=401)
return web.json_response({"email": email})
async def auth_logout(req):
tok = req.cookies.get("nd_user", "")
USER_SESSIONS.pop(tok, None)
resp = web.json_response({"ok": True})
resp.del_cookie("nd_user")
return resp
async def auth_forgot(req):
body = await req.json()
email = str(body.get("email", "")).strip().lower()
with _acct_db() as conn:
row = conn.execute("SELECT id FROM users WHERE email=? AND verified=1", (email,)).fetchone()
if row:
tok = _secrets.token_urlsafe(24)
conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?",
(tok, int(time.time()) + 1800, row[0]))
link = ARGS.public_url + "/?reset=" + tok
send_mail(email, "Reset your NetDesk password",
_mail_html("Password reset",
"Click below to choose a new password. "
"The link expires in 30 minutes.", link, "Reset password"))
return web.json_response(
{"ok": True, "msg": "if that account exists, a reset email was sent"})
async def auth_reset(req):
body = await req.json()
tok, pw = str(body.get("token", "")), str(body.get("password", ""))
if len(pw) < 8:
return web.json_response({"error": "password needs 8+ characters"}, status=400)
with _acct_db() as conn:
row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?",
(tok, int(time.time()))).fetchone()
if not row:
return web.json_response({"error": "link expired or invalid"}, status=400)
salt = _secrets.token_bytes(16)
conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL "
"WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0]))
log_event("auth", "password reset")
return web.json_response({"ok": True})
'''
# insert block before portal section
s = s.replace(ANCHOR, BLOCK + ANCHOR, 1)
# CLI args
s = s.replace(
' ap.add_argument("--access-path", dest="access_path",\n'
' help="secret URL segment hosting the UI (random hash); root serves 404")',
' ap.add_argument("--access-path", dest="access_path",\n'
' help="secret URL segment hosting the UI (random hash); root serves 404")\n'
' ap.add_argument("--mail-from", dest="mail_from", default="",\n'
' help="sender address enabling email accounts, e.g. noreply@netdesk.space")\n'
' ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1",\n'
' help="SMTP relay for verification mail (default localhost postfix)")\n'
' ap.add_argument("--public-url", dest="public_url", default="",\n'
' help="public base URL used in email links, e.g. https://netdesk.space")')
# middleware-free auth routes (public)
s = s.replace(
' app.router.add_get("/tunnel/{hid}", tunnel_ws)',
' app.router.add_get("/tunnel/{hid}", tunnel_ws)\n'
' # viewer email accounts\n'
' app.router.add_post("/api/auth/signup", auth_signup)\n'
' app.router.add_post("/api/auth/login", auth_login)\n'
' app.router.add_post("/api/auth/forgot", auth_forgot)\n'
' app.router.add_post("/api/auth/reset", auth_reset)\n'
' app.router.add_get("/api/auth/verify", auth_verify)\n'
' app.router.add_get("/api/auth/me", auth_me)\n'
' app.router.add_post("/api/auth/logout", auth_logout)')
# print line in main
s = s.replace(
' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")',
' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")\n'
' if ARGS.mail_from:\n'
' print(f"[netdesk] viewer accounts ON - mail from {ARGS.mail_from} via {ARGS.smtp_host}:25")')
open(p, "w", encoding="utf-8").write(s)
print("server accounts patch applied")