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 from importlib.resources import files
import msgspec import msgspec
from sanic import Sanic from html5tagger import E
from sanic import Sanic, redirect
from sanic.log import logger from sanic.log import logger
from sanic.response import html 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 .fileio import ROOT, FileServer
from .protocol import ErrorMsg, FileRange, StatusMsg from .protocol import ErrorMsg, FileRange, StatusMsg
app = Sanic("cista") app = Sanic("cista")
fileserver = FileServer() fileserver = FileServer()
watching.register(app, "/api/watch") watching.register(app, "/api/watch")
app.blueprint(authbp)
def asend(ws, msg): def asend(ws, msg):
return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode()) 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("/") @app.get("/")
async def index_page(request): 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() 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) return html(index)
app.static("/files", ROOT, use_content_range=True, stream_large_files=True, directory_view=True) 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) logger.exception(repr(res), e)
return return
@app.websocket("/ws")
async def ws(request, ws):
while True:
data = await ws.recv()
await ws.send(data)
@app.websocket('/api/download') @app.websocket('/api/download')
async def download(request, ws): async def download(request, ws):
alink = fileserver.alink 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 from __future__ import annotations
import hmac
import re
import secrets import secrets
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
from time import time from time import time
from unicodedata import normalize
import argon2
import msgspec import msgspec
_argon = argon2.PasswordHasher()
_droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$')
class Config(msgspec.Struct): class Config(msgspec.Struct):
path: Path = Path.cwd() path: Path = Path.cwd()
secret: str = secrets.token_hex(12) secret: str = secrets.token_hex(12)
public: bool = False public: bool = False
users: dict[str, User] = {} users: dict[str, User] = {}
sessions: dict[str, Session] = {}
links: dict[str, Link] = {} links: dict[str, Link] = {}
class User(msgspec.Struct, omit_defaults=True): class User(msgspec.Struct, omit_defaults=True):
@ -28,13 +21,6 @@ class User(msgspec.Struct, omit_defaults=True):
hash: str = "" hash: str = ""
lastSeen: int = 0 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): class Link(msgspec.Struct, omit_defaults=True):
location: str location: str
creator: str = "" creator: str = ""
@ -54,39 +40,6 @@ def derived_secret(*params, len=8) -> bytes:
# Output a bytes of the desired length # Output a bytes of the desired length
return sha256(combined).digest()[:len] 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): def enc_hook(obj):
if isinstance(obj, PurePath): 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 asyncio
import secrets import secrets
from pathlib import Path, PurePosixPath
import threading import threading
from watchdog.events import FileSystemEventHandler from pathlib import Path, PurePosixPath
from watchdog.observers import Observer
import msgspec import msgspec
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from . import config from . import config
from .fileio import ROOT from .fileio import ROOT

View File

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