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
+19
View File
@@ -23,6 +23,25 @@ python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7
| `-p`, `--port` | `8888` | Port | | `-p`, `--port` | `8888` | Port |
| `-d`, `--dir` | `~/Downloads/shared` | Upload directory | | `-d`, `--dir` | `~/Downloads/shared` | Upload directory |
| `--ttl` | `7` | File lifetime in days (0 = keep forever) | | `--ttl` | `7` | File lifetime in days (0 = keep forever) |
| `--token` | | Auth token (empty = open access) |
| `--cert` | | Path to SSL certificate |
| `--key` | | Path to SSL key |
## Authentication
Set `token` in `share.config` or pass `--token`. All endpoints require a valid token.
The token can be stored as plain text or as a SHA-256 hash:
```
# generate hash
python3 share.py hash mytoken
# store in share.config
token = 9c56cc51b374c3ba189210d5b6d0d04...
```
If the value is a 64-character hex string, it is treated as a hash. Otherwise it is hashed automatically at startup.
## systemd (Arch) ## systemd (Arch)
+5 -1
View File
@@ -11,7 +11,8 @@ _cp.read_dict({
"server": { "server": {
"port": "3001", "dir": str(Path.home() / "Downloads" / "shared"), "port": "3001", "dir": str(Path.home() / "Downloads" / "shared"),
"ttl": "7", "san": "", "lang": "ru", "refresh": "5", "ttl": "7", "san": "", "lang": "ru", "refresh": "5",
"per_page": "10,25,50,100", "per_page": "10,25,50,100", "token": "",
"cert": "", "key": "",
}, },
"internal": { "internal": {
"secs_per_day": "86400", "cleanup_interval": "3600", "secs_per_day": "86400", "cleanup_interval": "3600",
@@ -42,6 +43,9 @@ def load_config() -> dict:
p.add_argument("--san", nargs="*") p.add_argument("--san", nargs="*")
p.add_argument("--lang") p.add_argument("--lang")
p.add_argument("--refresh", type=int) p.add_argument("--refresh", type=int)
p.add_argument("--token")
p.add_argument("--cert", help="Path to SSL certificate file")
p.add_argument("--key", help="Path to SSL key file")
args = p.parse_args() args = p.parse_args()
for key, val in vars(args).items(): for key, val in vars(args).items():
+127 -4
View File
@@ -1,6 +1,8 @@
"""HTTP server and request handler.""" """HTTP server and request handler."""
import email.parser import email.parser
import hashlib
import hmac
import http.server import http.server
import json import json
import mimetypes import mimetypes
@@ -27,11 +29,17 @@ def file_type(path: Path) -> str:
class ShareServer(http.server.ThreadingHTTPServer): 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) super().__init__(addr, handler)
self.upload_dir = upload_dir self.upload_dir = upload_dir
self.html = html self.html = html
self.ssl_ctx = ssl_ctx 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): def get_request(self):
client, addr = self.socket.accept() client, addr = self.socket.accept()
@@ -70,6 +78,73 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(data) 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 ──
ROUTES = { ROUTES = {
@@ -78,14 +153,29 @@ class Handler(http.server.BaseHTTPRequestHandler):
("/index.html","_route_index"), ("/index.html","_route_index"),
("/files", "_route_files"), ("/files", "_route_files"),
("/dl/", "_route_download"), ("/dl/", "_route_download"),
("/logout", "_route_logout"),
], ],
"POST": [("/upload", "_route_upload")], "POST": [("/upload", "_route_upload"), ("/auth", "_route_auth")],
"DELETE": [("/del/", "_route_delete")], "DELETE": [("/del/", "_route_delete")],
} }
def _dispatch(self): def _dispatch(self):
parsed = urllib.parse.urlparse(self.path)
path_only = parsed.path
for prefix, method in self.ROUTES.get(self.command, []): 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) return getattr(self, method)(prefix)
self.send_error(404) 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]) 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: 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 return self.server.upload_dir / Path(name).name
def _route_download(self, prefix): def _route_download(self, prefix):
@@ -141,6 +232,38 @@ class Handler(http.server.BaseHTTPRequestHandler):
count = self._save_parts(ct, body) count = self._save_parts(ct, body)
self._json({"ok": True, "count": count}) 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): def _route_delete(self, prefix):
safe = self._safe_path(prefix) safe = self._safe_path(prefix)
if safe.is_file(): if safe.is_file():
+1
View File
@@ -5,6 +5,7 @@ ttl = 7
san = san =
lang = ru lang = ru
refresh = 5 refresh = 5
token = 755b7c9ca04986c0dd496353e9d94ce713f2df0657b9b4008cb4d617679e103f
per_page = 10,25,50,100 per_page = 10,25,50,100
[internal] [internal]
+14 -1
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Share — local file sharing server (HTTPS).""" """Share — local file sharing server (HTTPS)."""
import hashlib
import json import json
import ssl import ssl
import sys
import threading import threading
from pathlib import Path from pathlib import Path
@@ -13,6 +15,11 @@ from server import Handler, ShareServer
def main(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "hash":
token = sys.argv[2] if len(sys.argv) > 2 else input("Token: ")
print(hashlib.sha256(token.encode()).hexdigest())
return
conf = load_config() conf = load_config()
port = int(conf["port"]) port = int(conf["port"])
@@ -29,12 +36,18 @@ def main():
html = (BASE_DIR / "static" / "index.html").read_bytes() html = (BASE_DIR / "static" / "index.html").read_bytes()
html = html.replace(b"<!--CFG-->", cfg_tag) html = html.replace(b"<!--CFG-->", cfg_tag)
if conf["cert"] and conf["key"]:
cert, key = Path(conf["cert"]), Path(conf["key"])
else:
cert_dir = BASE_DIR / ".certs" cert_dir = BASE_DIR / ".certs"
cert, key = ensure_cert(cert_dir, extra_ips=san_list) cert, key = ensure_cert(cert_dir, extra_ips=san_list)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert, key) ctx.load_cert_chain(cert, key)
server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx) token = conf["token"]
# If token looks like a SHA-256 hash, pass it as pre-hashed
is_hash = len(token) == 64 and all(c in "0123456789abcdef" for c in token)
server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx, token=token, token_is_hash=is_hash)
print("\n Share running") print("\n Share running")
print(f" Local: https://localhost:{port}") print(f" Local: https://localhost:{port}")
+19 -5
View File
@@ -127,6 +127,14 @@ h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff }
margin-bottom: 16px; position: relative; margin-bottom: 16px; position: relative;
} }
#header h1 { margin-bottom: 0 } #header h1 { margin-bottom: 0 }
#logout-btn {
position: absolute; left: 0;
background: none; border: none; cursor: pointer;
color: var(--text-muted); padding: 4px;
display: inline-flex; align-items: center;
transition: color .2s;
}
#logout-btn:hover { color: var(--danger) }
#lang-sw { #lang-sw {
position: absolute; right: 0; position: absolute; right: 0;
display: flex; background: var(--bg-card-hover); border-radius: 6px; display: flex; background: var(--bg-card-hover); border-radius: 6px;
@@ -156,6 +164,7 @@ h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff }
</symbol> </symbol>
</svg> </svg>
<div id="header"> <div id="header">
<button id="logout-btn" title="Logout"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
<h1 id="title"></h1> <h1 id="title"></h1>
<div id="lang-sw"><span data-l="ru">RU</span><span data-l="en">EN</span></div> <div id="lang-sw"><span data-l="ru">RU</span><span data-l="en">EN</span></div>
</div> </div>
@@ -218,6 +227,7 @@ const I18N = {
uploadError: 'Ошибка соединения', uploadError: 'Ошибка соединения',
deleted: 'Удалено', deleted: 'Удалено',
perPage: 'На странице:', perPage: 'На странице:',
logout: 'Выход',
sz: [' Б', ' КБ', ' МБ', ' ГБ'], sz: [' Б', ' КБ', ' МБ', ' ГБ'],
}, },
en: { en: {
@@ -238,6 +248,7 @@ const I18N = {
uploadError: 'Upload error', uploadError: 'Upload error',
deleted: 'Deleted', deleted: 'Deleted',
perPage: 'Per page:', perPage: 'Per page:',
logout: 'Logout',
sz: [' B', ' KB', ' MB', ' GB'], sz: [' B', ' KB', ' MB', ' GB'],
} }
}; };
@@ -254,6 +265,7 @@ function setLang(l) {
$('drop-text').textContent = t().drop; $('drop-text').textContent = t().drop;
$('drop-hint').textContent = t().hint; $('drop-hint').textContent = t().hint;
$('files-title').textContent = t().files; $('files-title').textContent = t().files;
$('logout-btn').title = t().logout;
loadFiles(); loadFiles();
} }
@@ -326,7 +338,7 @@ function toPngBlob(blob) {
} }
function copyFile(name, type) { function copyFile(name, type) {
const url = '/dl/' + encodeURIComponent(name); const url = 'dl/' + encodeURIComponent(name);
if (type === 'text') { if (type === 'text') {
fetch(url).then(r => { if (!r.ok) throw 0; return r.text() }).then(text => copyText(text)) fetch(url).then(r => { if (!r.ok) throw 0; return r.text() }).then(text => copyText(text))
@@ -382,7 +394,7 @@ function upload(fileList) {
}; };
xhr.onerror = () => { done(); toast(t().uploadError) }; xhr.onerror = () => { done(); toast(t().uploadError) };
xhr.open('POST', '/upload'); xhr.open('POST', 'upload');
xhr.send(fd); xhr.send(fd);
} }
@@ -456,7 +468,7 @@ function renderFiles() {
<span class="fname" data-action="copy" data-name="${esc(f.name)}" data-type="${f.type}" title="${t().copy}">${esc(f.name)}</span> <span class="fname" data-action="copy" data-name="${esc(f.name)}" data-type="${f.type}" title="${t().copy}">${esc(f.name)}</span>
<div class="meta"> <div class="meta">
<span class="fsize">${fmtSize(f.size)}</span> <span class="fsize">${fmtSize(f.size)}</span>
<a class="btn dl" href="/dl/${encodeURIComponent(f.name)}" title="${t().download}"><svg width="14" height="14"><use href="#icon-dl"/></svg></a> <a class="btn dl" href="dl/${encodeURIComponent(f.name)}" title="${t().download}"><svg width="14" height="14"><use href="#icon-dl"/></svg></a>
<span class="btn del" data-action="del" data-name="${esc(f.name)}" title="${t().delete}"><svg width="14" height="14"><use href="#icon-del"/></svg></span> <span class="btn del" data-action="del" data-name="${esc(f.name)}" title="${t().delete}"><svg width="14" height="14"><use href="#icon-del"/></svg></span>
</div> </div>
</div>`).join(''); </div>`).join('');
@@ -465,7 +477,7 @@ function renderFiles() {
} }
function loadFiles() { function loadFiles() {
fetch('/files').then(r => r.json()).then(files => { fetch('files').then(r => r.json()).then(files => {
allFiles = files; allFiles = files;
renderFiles(); renderFiles();
}).catch(() => {}); }).catch(() => {});
@@ -480,7 +492,7 @@ flist.addEventListener('click', e => {
const name = el.dataset.name; const name = el.dataset.name;
if (action === 'copy') copyFile(name, el.dataset.type); if (action === 'copy') copyFile(name, el.dataset.type);
else if (action === 'del') { else if (action === 'del') {
fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' }) fetch('del/' + encodeURIComponent(name), { method: 'DELETE' })
.then(() => { toast(t().deleted); loadFiles() }) .then(() => { toast(t().deleted); loadFiles() })
.catch(() => {}); .catch(() => {});
} }
@@ -532,6 +544,8 @@ document.addEventListener('paste', e => {
} }
}); });
$('logout-btn').addEventListener('click', () => { window.location = 'logout' });
setLang(lang); setLang(lang);
let pollId = setInterval(loadFiles, REFRESH_MS); let pollId = setInterval(loadFiles, REFRESH_MS);
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {