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
|
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
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
|
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
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 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
|
||||||
|
|
|
@ -17,6 +17,7 @@ dependencies = [
|
||||||
"argon2-cffi",
|
"argon2-cffi",
|
||||||
"msgspec",
|
"msgspec",
|
||||||
"pathvalidate",
|
"pathvalidate",
|
||||||
|
"pyjwt",
|
||||||
"sanic",
|
"sanic",
|
||||||
"tomli_w",
|
"tomli_w",
|
||||||
"watchdog",
|
"watchdog",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user