feat(auth): add token authentication with SHA-256 hashed storage
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user