diff --git a/cista/app.py b/cista/app.py index a5272e5..9217c43 100644 --- a/cista/app.py +++ b/cista/app.py @@ -1,18 +1,21 @@ -import asyncio from importlib.resources import files import msgspec -from sanic import Sanic +from html5tagger import E +from sanic import Sanic, redirect from sanic.log import logger from sanic.response import html -from . import watching +from . import session, watching +from .auth import authbp +from .config import config from .fileio import ROOT, FileServer from .protocol import ErrorMsg, FileRange, StatusMsg app = Sanic("cista") fileserver = FileServer() watching.register(app, "/api/watch") +app.blueprint(authbp) def asend(ws, msg): return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode()) @@ -27,7 +30,14 @@ async def stop_fileserver(app, _): @app.get("/") async def index_page(request): + s = config.public or session.get(request) + print("Main session", s) + if not s: + return redirect("/login") index = files("cista").joinpath("static", "index.html").read_text() + flash = request.cookies.flash + if flash: + index += str(E.div(flash, id="flash")) return html(index) app.static("/files", ROOT, use_content_range=True, stream_large_files=True, directory_view=True) @@ -59,12 +69,6 @@ async def upload(request, ws): logger.exception(repr(res), e) return -@app.websocket("/ws") -async def ws(request, ws): - while True: - data = await ws.recv() - await ws.send(data) - @app.websocket('/api/download') async def download(request, ws): alink = fileserver.alink diff --git a/cista/auth.py b/cista/auth.py new file mode 100644 index 0000000..691be43 --- /dev/null +++ b/cista/auth.py @@ -0,0 +1,117 @@ +import hmac +import re +from time import time +from unicodedata import normalize + +import argon2 +import msgspec +from html5tagger import Document +from sanic import Blueprint, html, json, redirect + +from . import session +from .config import User, config + +_argon = argon2.PasswordHasher() +_droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$') + +def _pwnorm(password): + return normalize('NFC', password).strip().encode() + +def login(username: str, password: str): + un = _pwnorm(username) + pw = _pwnorm(password) + try: + u = config.users[un.decode()] + except KeyError: + raise ValueError("Invalid username") + # Verify password + need_rehash = False + if not u.hash: + raise ValueError("Account disabled") + if (m := _droppyhash.match(u.hash)) is not None: + h, s = m.groups() + h2 = hmac.digest(pw + s.encode() + un, b"", "sha256").hex() + if not hmac.compare_digest(h, h2): + raise ValueError("Invalid password") + # Droppy hashes are weak, do a hash update + need_rehash = True + else: + try: + _argon.verify(u.hash, pw) + except Exception: + raise ValueError("Invalid password") + if _argon.check_needs_rehash(u.hash): + need_rehash = True + # Login successful + if need_rehash: + u.set_password(password) + now = int(time()) + u.lastSeen = now + return u + +def set_password(user: User, password: str): + user.hash = _argon.hash(_pwnorm(password)) + +class LoginResponse(msgspec.Struct): + user: str = "" + privileged: bool = False + error: str = "" + +authbp = Blueprint("auth") + +@authbp.get("/login") +async def login_page(request): + doc = Document("Cista Login") + with doc.div(id="login"): + with doc.form(method="POST", autocomplete="on"): + doc.h1("Login") + doc.input(name="username", placeholder="Username", autocomplete="username").br + doc.input(type="password", name="password", placeholder="Password", autocomplete="current-password").br + doc.input(type="submit", value="Login") + s = session.get(request) + if s: + name = s["username"] + with doc.form(method="POST", action="/logout"): + doc.input(type="submit", value=f"Logout {name}") + flash = request.cookies.flash + if flash: + doc.p(flash) + res = html(doc) + if flash: + res.cookies.delete_cookie("flash") + if s is False: + session.delete(res) + return res + +@authbp.post("/login") +async def login_post(request): + json_format = request.headers.content_type == "application/json" + if json_format: + username = request.json["username"] + password = request.json["password"] + else: + print(request.form) + username = request.form["username"][0] + password = request.form["password"][0] + if not username or not password: + raise ValueError("Missing username or password") + user = login(username, password) + + if json_format: + res = json({ + "status": "authenticated", + "user": username, + "privileged": user.privileged, + }) + else: + res = redirect("/") + res.cookies.add_cookie("flash", "Logged in", host_prefix=True, max_age=5) + session.create(res, username) + return res + +@authbp.post("/logout") +async def logout_post(request): + res = redirect("/") + session.delete(res) + res.cookies.add_cookie("flash", "Logged out",host_prefix=True, max_age=5) + return res diff --git a/cista/config.py b/cista/config.py index d2c7938..5a944ac 100644 --- a/cista/config.py +++ b/cista/config.py @@ -1,26 +1,19 @@ from __future__ import annotations -import hmac -import re import secrets from functools import wraps from hashlib import sha256 from pathlib import Path, PurePath from time import time -from unicodedata import normalize -import argon2 import msgspec -_argon = argon2.PasswordHasher() -_droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$') class Config(msgspec.Struct): path: Path = Path.cwd() secret: str = secrets.token_hex(12) public: bool = False users: dict[str, User] = {} - sessions: dict[str, Session] = {} links: dict[str, Link] = {} class User(msgspec.Struct, omit_defaults=True): @@ -28,13 +21,6 @@ class User(msgspec.Struct, omit_defaults=True): hash: str = "" lastSeen: int = 0 - def set_password(self, password: str): - self.hash = _argon.hash(_pwnorm(password)) - -class Session(msgspec.Struct): - username: str - lastSeen: int - class Link(msgspec.Struct, omit_defaults=True): location: str creator: str = "" @@ -54,39 +40,6 @@ def derived_secret(*params, len=8) -> bytes: # Output a bytes of the desired length return sha256(combined).digest()[:len] -def _pwnorm(password): - return normalize('NFC', password).strip().encode() - -def login(username: str, password: str): - un = _pwnorm(username) - pw = _pwnorm(password) - try: - u = config.users[un.decode()] - except KeyError: - raise ValueError("Invalid username") - # Verify password - if not u.hash: - raise ValueError("Account disabled") - if (m := _droppyhash.match(u.hash)) is not None: - h, s = m.groups() - h2 = hmac.digest(pw + s.encode() + un, b"", "sha256").hex() - if not hmac.compare_digest(h, h2): - raise ValueError("Invalid password") - # Droppy hashes are weak, do a hash update - u.set_password(password) - else: - try: - _argon.verify(u.hash, pw) - except Exception: - raise ValueError("Invalid password") - if _argon.check_needs_rehash(u.hash): - u.set_password(password) - # Login successful - now = int(time()) - u.lastSeen = now - sid = secrets.token_urlsafe(12) - config.sessions[sid] = Session(username, now) - return u, sid def enc_hook(obj): if isinstance(obj, PurePath): diff --git a/cista/session.py b/cista/session.py new file mode 100644 index 0000000..3cc9d4f --- /dev/null +++ b/cista/session.py @@ -0,0 +1,32 @@ +from time import time + +import jwt + +from .config import derived_secret + +session_secret = derived_secret("session") +max_age = 60 # Seconds since last login + +def get(request): + try: + return jwt.decode(request.cookies.s, session_secret, algorithms=["HS256"]) + except Exception as e: + s = None + return False if "s" in request.cookies else None + +def create(res, username, **kwargs): + data = { + "exp": int(time()) + max_age, + "username": username, + **kwargs, + } + s = jwt.encode(data, session_secret) + res.cookies.add_cookie("s", s, host_prefix=True, httponly=True, max_age=max_age) + +def update(res, s, **kwargs): + s.update(kwargs) + s = jwt.encode(s, session_secret) + res.cookies.add_cookie("s", s, host_prefix=True, httponly=True, max_age=max(1, s["exp"] - int(time()))) + +def delete(res): + res.cookies.delete_cookie("s", host_prefix=True) diff --git a/cista/watching.py b/cista/watching.py index 087432e..896f8b3 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -1,11 +1,11 @@ import asyncio import secrets -from pathlib import Path, PurePosixPath import threading -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer +from pathlib import Path, PurePosixPath import msgspec +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer from . import config from .fileio import ROOT diff --git a/pyproject.toml b/pyproject.toml index 498a599..0fe66c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "argon2-cffi", "msgspec", "pathvalidate", + "pyjwt", "sanic", "tomli_w", "watchdog",