diff --git a/development.ini b/development.ini index 971bcf7..89439eb 100644 --- a/development.ini +++ b/development.ini @@ -6,6 +6,11 @@ [app:main] use = egg:superx_budget +pwd.db = + $argon2id$v=19$m=102400,t=2,p=8$f48xZqyVUsoZg5AyJmRszQ$5Bn/u67+2pHNBxe5g0UFnw + $argon2id$v=19$m=102400,t=2,p=8$vheCMKa0dq7V2nuPUWrtXQ$pfomI8eG74mKulf1Elp0JA + +auth.secret = "change me in production" session.secret = "change me in production" budgets.dir = %(here)s/test_data diff --git a/pyproject.toml b/pyproject.toml index 438e29b..56149ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ requires-python = ">=3.7" [tool.flit.metadata.requires-extra] test = [ + "passlib[argon2] >= 1.7.2", "pytest >=4.0.0", "pytest-cov", "pytest-mock", diff --git a/superx_budget/pyramid/__init__.py b/superx_budget/pyramid/__init__.py index 6d398d9..535313d 100644 --- a/superx_budget/pyramid/__init__.py +++ b/superx_budget/pyramid/__init__.py @@ -5,6 +5,7 @@ from pathlib import Path from pyramid.view import notfound_view_config from pyramid.config import Configurator from pyramid.session import JSONSerializer, SignedCookieSessionFactory +from pyramid.security import Allow, Everyone, Authenticated from pyramid.httpexceptions import HTTPFound from ..overview import create_overview # noqa: F401 @@ -13,22 +14,24 @@ from ..exceptions import BudgetParserError, SuperXParserError # noqa: F401 XLSX_CONTENT_TYPE = "application/vnd.ms-excel" -def root_factory(request): - return {} +class Root: + + __acl__ = [(Allow, Everyone, "login"), (Allow, Authenticated, "view")] + + def __init__(self, request): + pass def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include("pyramid_jinja2") - session_factory = SignedCookieSessionFactory( settings["session.secret"], serializer=JSONSerializer() ) config.set_session_factory(session_factory) - config.set_root_factory(root_factory) + config.set_root_factory(Root) config.add_request_method( lambda r: Path(settings["budgets.dir"]), "budgets_dir", reify=True, @@ -37,6 +40,9 @@ def main(global_config, **settings): age = int(settings.get("static_views.cache_max_age", 0)) config.add_static_view("static", "static", cache_max_age=age) + config.include("pyramid_jinja2") + config.include(".security") + config.scan() return config.make_wsgi_app() diff --git a/superx_budget/pyramid/overview.py b/superx_budget/pyramid/overview.py index 0884741..a615813 100644 --- a/superx_budget/pyramid/overview.py +++ b/superx_budget/pyramid/overview.py @@ -6,7 +6,7 @@ from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound from pyramid_mailer.message import Message, Attachment -from . import XLSX_CONTENT_TYPE +from . import XLSX_CONTENT_TYPE, Root from ..budget import parse_budget_file from ..superx import parse_exported_file from ..helpers import find_recipients, find_budget_file, get_sheet_of_file @@ -27,18 +27,20 @@ bei Fragen bitte an Holgi wenden @view_config( - context=dict, + context=Root, request_method="GET", renderer="superx_budget:pyramid/templates/start.jinja2", + permission="view", ) def index(context, request): return {} @view_config( - context=dict, + context=Root, request_method="POST", renderer="superx_budget:pyramid/templates/overview.jinja2", + permission="view", ) def superx_upload(context, request): upload = request.POST.get("superx") @@ -98,10 +100,11 @@ def superx_upload(context, request): @view_config( - context=dict, + context=Root, name="send", request_method="POST", renderer="superx_budget:pyramid/templates/sent.jinja2", + permission="view", ) def send_overview(context, request): export_date = request.POST.get("export_date").strip() diff --git a/superx_budget/pyramid/security.py b/superx_budget/pyramid/security.py new file mode 100644 index 0000000..c969f81 --- /dev/null +++ b/superx_budget/pyramid/security.py @@ -0,0 +1,68 @@ +from passlib.hash import argon2 +from pyramid.view import view_config, forbidden_view_config +from pyramid.security import forget, remember +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.httpexceptions import HTTPFound + +from . import Root + +AUTHENTICATED_USER_ID = "authenticated" + + +class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): + def authenticated_userid(self, request): + user = request.user + if user is not None: + return AUTHENTICATED_USER_ID + + +def get_user(request): + return request.unauthenticated_userid + + +@forbidden_view_config( + renderer="superx_budget:pyramid/templates/login.jinja2", +) +def forbidden_view(request): + return {"error": False} + + +@view_config( + context=Root, + name="login", + request_method="POST", + permission="login", + renderer="superx_budget:pyramid/templates/login.jinja2", +) +def login(request): + if request.check_password(): + headers = remember(request, AUTHENTICATED_USER_ID, max_age=3600) + return HTTPFound("/", headers=headers) + return {"error": True} + + +@view_config( + context=Root, name="logout", permission="login", +) +def logout(request): + headers = forget(request) + return HTTPFound("/", headers=headers) + + +def includeme(config): + settings = config.get_settings() + authn_policy = MyAuthenticationPolicy( + settings["auth.secret"], hashalg="sha512", + ) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(ACLAuthorizationPolicy()) + + hashes = [hash for hash in settings["pwd.db"].splitlines() if hash] + + def check_password(request): + password = request.POST.get("password", "") + return any(argon2.verify(password, hash) for hash in hashes) + + config.add_request_method(check_password, "check_password") + config.add_request_method(get_user, "user", reify=True) diff --git a/superx_budget/pyramid/templates.py b/superx_budget/pyramid/templates.py index cb1ca40..4d3e437 100644 --- a/superx_budget/pyramid/templates.py +++ b/superx_budget/pyramid/templates.py @@ -6,7 +6,7 @@ from pyramid.view import view_config from pyramid.response import FileResponse from pyramid.httpexceptions import HTTPFound -from . import XLSX_CONTENT_TYPE +from . import XLSX_CONTENT_TYPE, Root from ..budget import parse_budget_file from ..helpers import list_budget_files, is_budget_file_name from ..overview import create_overview # noqa: F401 @@ -14,10 +14,11 @@ from ..exceptions import BudgetParserError, SuperXParserError # noqa: F401 @view_config( - context=dict, + context=Root, name="templates", request_method="GET", renderer="superx_budget:pyramid/templates/templates.jinja2", + permission="view", ) def templates(context, request): if "f" in request.GET: @@ -43,10 +44,11 @@ def templates(context, request): @view_config( - context=dict, + context=Root, name="templates", request_method="POST", renderer="superx_budget:pyramid/templates/templates.jinja2", + permission="view", ) def templates_update(context, request): upload = request.POST.get("budget") diff --git a/superx_budget/pyramid/templates/login.jinja2 b/superx_budget/pyramid/templates/login.jinja2 new file mode 100644 index 0000000..fc279a7 --- /dev/null +++ b/superx_budget/pyramid/templates/login.jinja2 @@ -0,0 +1,56 @@ + + +
+ + +