Implemented login page and new jwt-based sessions. Watching cleanup.
This commit is contained in:
parent
bd680e3668
commit
429a7dfb16
22
cista/app.py
22
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
|
||||
|
|
117
cista/auth.py
Normal file
117
cista/auth.py
Normal 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
|
|
@ -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
32
cista/session.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -17,6 +17,7 @@ dependencies = [
|
|||
"argon2-cffi",
|
||||
"msgspec",
|
||||
"pathvalidate",
|
||||
"pyjwt",
|
||||
"sanic",
|
||||
"tomli_w",
|
||||
"watchdog",
|
||||
|
|
Loading…
Reference in New Issue
Block a user