feat(auth): add token authentication with SHA-256 hashed storage

This commit is contained in:
2026-03-29 15:54:41 +04:00
parent 6a540eaf81
commit 6f6b7552fc
6 changed files with 187 additions and 13 deletions
+127 -4
View File
@@ -1,6 +1,8 @@
"""HTTP server and request handler."""
import email.parser
import hashlib
import hmac
import http.server
import json
import mimetypes
@@ -27,11 +29,17 @@ def file_type(path: Path) -> str:
class ShareServer(http.server.ThreadingHTTPServer):
def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None):
def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None, token: str = "", token_is_hash: bool = False):
super().__init__(addr, handler)
self.upload_dir = upload_dir
self.html = html
self.ssl_ctx = ssl_ctx
if not token:
self.token_hash = ""
elif token_is_hash:
self.token_hash = token
else:
self.token_hash = hashlib.sha256(token.encode()).hexdigest()
def get_request(self):
client, addr = self.socket.accept()
@@ -70,6 +78,73 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
# ── Auth ──
@staticmethod
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _match(self, value: str) -> bool:
return hmac.compare_digest(self._hash(value), self.server.token_hash)
def _check_auth(self) -> bool:
if not self.server.token_hash:
return True
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
if qs.get("token", [None])[0] and self._match(qs["token"][0]):
return True
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer ") and self._match(auth[7:]):
return True
cookie = self.headers.get("Cookie", "")
for part in cookie.split(";"):
part = part.strip()
if part.startswith("share_token=") and hmac.compare_digest(part[12:], self.server.token_hash):
return True
return False
_AUTH_PAGE = b'''<!DOCTYPE html>
<html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Share</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#141414;border-radius:12px;padding:40px 32px;width:100%;max-width:360px;text-align:center}
h1{font-size:1.3rem;color:#fff;margin-bottom:24px}
input{width:100%;padding:12px 14px;background:#1a1a1a;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:.95rem;outline:none;margin-bottom:16px;transition:border-color .2s}
input:focus{border-color:#4fc3f7}
button{width:100%;padding:12px;background:#4fc3f7;color:#000;border:none;border-radius:8px;font-size:.95rem;font-weight:600;cursor:pointer;transition:opacity .2s}
button:hover{opacity:.85}
.err{color:#ef5350;font-size:.85rem;margin-top:12px;min-height:1.2em}
</style>
</head><body>
<div class="card">
<h1>Share</h1>
<form id="f">
<input type="password" id="tok" placeholder="Token" autofocus>
<button type="submit">Login</button>
</form>
<div class="err" id="err"></div>
</div>
<script>
const f=document.getElementById('f'),tok=document.getElementById('tok'),err=document.getElementById('err');
f.onsubmit=e=>{e.preventDefault();err.textContent='';
fetch('auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:tok.value})})
.then(r=>{if(r.ok)location.reload();else{err.textContent='Invalid token';tok.select()}})
.catch(()=>{err.textContent='Connection error'})};
</script>
</body></html>'''
def _send_auth_page(self):
self.send_response(401)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(self._AUTH_PAGE)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(self._AUTH_PAGE)
# ── Routes ──
ROUTES = {
@@ -78,14 +153,29 @@ class Handler(http.server.BaseHTTPRequestHandler):
("/index.html","_route_index"),
("/files", "_route_files"),
("/dl/", "_route_download"),
("/logout", "_route_logout"),
],
"POST": [("/upload", "_route_upload")],
"POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")],
"DELETE": [("/del/", "_route_delete")],
}
def _dispatch(self):
parsed = urllib.parse.urlparse(self.path)
path_only = parsed.path
for prefix, method in self.ROUTES.get(self.command, []):
if self.path == prefix or (len(prefix) > 1 and prefix.endswith("/") and self.path.startswith(prefix)):
if path_only == prefix or (len(prefix) > 1 and prefix.endswith("/") and path_only.startswith(prefix)):
if method not in ("_route_auth",) and not self._check_auth():
if self.command == "GET" and path_only in ("/", "/index.html"):
return self._send_auth_page()
self.send_response(401)
body = json.dumps({"error": "unauthorized"}).encode()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
return
return getattr(self, method)(prefix)
self.send_error(404)
@@ -103,7 +193,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
self._json([{"name": p.name, "size": st.st_size, "type": file_type(p)} for p, st in entries])
def _safe_path(self, prefix: str) -> Path:
name = urllib.parse.unquote(self.path[len(prefix):])
parsed = urllib.parse.urlparse(self.path)
name = urllib.parse.unquote(parsed.path[len(prefix):])
return self.server.upload_dir / Path(name).name
def _route_download(self, prefix):
@@ -141,6 +232,38 @@ class Handler(http.server.BaseHTTPRequestHandler):
count = self._save_parts(ct, body)
self._json({"ok": True, "count": count})
def _route_auth(self, _prefix):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""
try:
data = json.loads(body)
except (json.JSONDecodeError, ValueError):
data = {}
if data.get("token") and self._match(data["token"]):
resp = json.dumps({"ok": True}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.send_header("Set-Cookie", f"share_token={self._hash(data['token'])}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000")
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(resp)
else:
resp = json.dumps({"ok": False}).encode()
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(resp)
def _route_logout(self, _prefix):
self.send_response(302)
self.send_header("Set-Cookie", "share_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")
self.send_header("Location", "/")
self.send_header("Connection", "close")
self.end_headers()
def _route_delete(self, prefix):
safe = self._safe_path(prefix)
if safe.is_file():