Implemented login page and new jwt-based sessions. Watching cleanup.

This commit is contained in:
Leo Vasanko 2023-10-18 01:06:27 +03:00 committed by Leo Vasanko
parent bd680e3668
commit 429a7dfb16
6 changed files with 166 additions and 59 deletions

View File

@ -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

117
cista/auth.py Normal file
View File

@ -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

View File

@ -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):

32
cista/session.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -17,6 +17,7 @@ dependencies = [
"argon2-cffi",
"msgspec",
"pathvalidate",
"pyjwt",
"sanic",
"tomli_w",
"watchdog",