From d9ef64d98330b678da3c38076a26a5c85857695c Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Wed, 30 Nov 2022 21:06:34 +0100 Subject: [PATCH] feat: implement authentication --- docker-compose.yml | 1 + lumi2/__init__.py | 4 ++ lumi2/auth.py | 68 +++++++++++++++++++ lumi2/templates/auth/login.html | 20 ++++++ lumi2/templates/base.html | 10 ++- lumi2/templates/usermanager/group_create.html | 1 - lumi2/templates/usermanager/index.html | 2 +- lumi2/usermanager.py | 10 +++ lumi2/webapi.py | 23 ++++++- 9 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 lumi2/auth.py create mode 100644 lumi2/templates/auth/login.html diff --git a/docker-compose.yml b/docker-compose.yml index c0e5858..c8af98d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - ./lumi2/__init__.py:/app/lumi2/__init__.py:ro - ./lumi2/exceptions.py:/app/lumi2/exceptions.py:ro - ./lumi2/ldap.py:/app/lumi2/ldap.py:ro + - ./lumi2/auth.py:/app/lumi2/auth.py:ro - ./lumi2/usermodel.py:/app/lumi2/usermodel.py:ro - ./lumi2/webapi.py:/app/lumi2/webapi.py:ro - ./lumi2/usermanager.py:/app/lumi2/usermanager.py:ro diff --git a/lumi2/__init__.py b/lumi2/__init__.py index c2508a6..eef5011 100644 --- a/lumi2/__init__.py +++ b/lumi2/__init__.py @@ -16,6 +16,7 @@ def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) app.config.from_mapping( SECRET_KEY='ChangeMeInProduction', + ADMIN_PASSWORD='pbkdf2:sha256:260000$J9yKJOAvWfvaO9Op$f959d88402f67a5143808a00e35d17e636546f1caf5a85c1b6ab1165d1780448', SITE_URL='https://www.example.com/', SITE_TITLE='LUMI 2', SITE_AUTHOR='LUMI 2 Development Team', @@ -44,6 +45,9 @@ def create_app(test_config=None): except OSError: pass + from . import auth + app.register_blueprint(auth.bp) + from . import usermanager app.register_blueprint(usermanager.bp) app.add_url_rule('/', endpoint='index') diff --git a/lumi2/auth.py b/lumi2/auth.py new file mode 100644 index 0000000..22a80f5 --- /dev/null +++ b/lumi2/auth.py @@ -0,0 +1,68 @@ +import functools + +from flask import ( + Blueprint, current_app, g, flash, redirect, url_for, session, + render_template +) +from werkzeug.security import check_password_hash + +from flask_wtf import FlaskForm +from wtforms import ValidationError, PasswordField, SubmitField +from wtforms.validators import InputRequired + + +bp = Blueprint('auth', __name__) + +class LoginForm(FlaskForm): + @staticmethod + def validate_password(form, field) -> None: + if not field.data: + raise ValidationError("Please enter a password.") + if not check_password_hash(current_app.config['ADMIN_PASSWORD'], field.data): + raise ValidationError("Invalid password.") + + password = PasswordField( + 'Password', + [InputRequired('Please enter a password.'), validate_password], + ) + + submit = SubmitField( + 'Log In', + ) + +@bp.route("/login", methods=("GET", "POST")) +def login(): + form = LoginForm() + if form.validate_on_submit(): + session.clear() + session['is_authenticated'] = True + return redirect(url_for('index')) + + return render_template('auth/login.html', form=form) + + +@bp.before_app_request +def load_logged_in_user(): + authentication_status = session.get('is_authenticated') + if authentication_status: + g.is_authenticated = authentication_status + else: + g.is_authenticated = False + + +@bp.route('/logout') +def logout(): + session.clear() + flash("You were logged out.") + return redirect(url_for('index')) + + +def login_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if not g.is_authenticated: + return redirect(url_for('auth.login')) + + return view(**kwargs) + + return wrapped_view diff --git a/lumi2/templates/auth/login.html b/lumi2/templates/auth/login.html new file mode 100644 index 0000000..03b57a7 --- /dev/null +++ b/lumi2/templates/auth/login.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Log In

+
+
+
+ {{ form.csrf_token }} +
+ {{ form.password.label(class="col-sm-2 col-form-label text-center") }} + {{ form.password(class="col-sm form-control m-2" + (" is-invalid" if form.password.errors else "")) }} + {{ form.submit(class_="col-sm-2 btn btn-primary m-2") }} + {% if form.password.errors %} +
{{ form.password.errors[0] }}
+ {% endif %} +
+
+{% endblock content %} diff --git a/lumi2/templates/base.html b/lumi2/templates/base.html index 3c6f8fa..f401c45 100644 --- a/lumi2/templates/base.html +++ b/lumi2/templates/base.html @@ -45,11 +45,19 @@ Groups + {% if g.is_authenticated %} +
+ Log Out +
+ {% else %}
Log In
+ {% endif%} diff --git a/lumi2/templates/usermanager/group_create.html b/lumi2/templates/usermanager/group_create.html index f3d4cce..cc42d17 100644 --- a/lumi2/templates/usermanager/group_create.html +++ b/lumi2/templates/usermanager/group_create.html @@ -11,7 +11,6 @@ Cancel -

diff --git a/lumi2/templates/usermanager/index.html b/lumi2/templates/usermanager/index.html index c873a0f..10914d4 100644 --- a/lumi2/templates/usermanager/index.html +++ b/lumi2/templates/usermanager/index.html @@ -10,6 +10,6 @@ >
-

This site is still under construction.

+

This site is still under construction.

{% endblock content %} diff --git a/lumi2/usermanager.py b/lumi2/usermanager.py index 127d9a8..9203a96 100644 --- a/lumi2/usermanager.py +++ b/lumi2/usermanager.py @@ -16,6 +16,7 @@ from wtforms import ( ) from wtforms.validators import InputRequired, Email, EqualTo +from lumi2.auth import login_required import lumi2.ldap as ldap from lumi2.usermodel import User, Group from lumi2.exceptions import InvalidStringFormatException, InvalidImageException @@ -59,6 +60,7 @@ def index(): @bp.route("/users/view/") +@login_required def user_view(username: str): """Detail view for a specific User. @@ -81,6 +83,7 @@ def user_view(username: str): @bp.route("/users/list") +@login_required def user_list(): """Displays a list of all users.""" @@ -198,6 +201,7 @@ class UserCreationForm(UserUpdateForm): @bp.route("/users/create", methods=("GET", "POST")) +@login_required def user_create(): """Creation view for a new User. @@ -236,6 +240,7 @@ def user_create(): @bp.route("/users/update/", methods=("GET", "POST")) +@login_required def user_update(username: str): """Update view for a specific User. @@ -285,6 +290,7 @@ def user_update(username: str): @bp.route("/users/delete/", methods=("GET", "POST")) +@login_required def user_delete(username: str): """Deletion view for a specific User. @@ -327,6 +333,7 @@ def user_delete(username: str): @bp.route("/groups/list") +@login_required def group_list(): """Displays a list of all groups.""" @@ -344,6 +351,7 @@ def group_list(): @bp.route("/groups/create") +@login_required def group_create(): """Creation view for a new group. @@ -365,6 +373,7 @@ def group_create(): @bp.route("/groups/update/") +@login_required def group_update(groupname: str): """Detail and Update view for a group. @@ -395,6 +404,7 @@ def group_update(groupname: str): @bp.route("/groups/delete/", methods=("GET", "POST")) +@login_required def group_delete(groupname: str): """Deletion view for a specific Group. diff --git a/lumi2/webapi.py b/lumi2/webapi.py index 8c0e50e..77101f3 100644 --- a/lumi2/webapi.py +++ b/lumi2/webapi.py @@ -1,13 +1,18 @@ from json import JSONEncoder, JSONDecoder, loads, dumps, JSONDecodeError -from flask import Blueprint, request -from flask_restful import Resource +from flask import request, g +from flask_restful import Resource, abort import lumi2.ldap as ldap from lumi2.usermodel import User, Group from lumi2.exceptions import InvalidStringFormatException +def _assert_is_authenticated(): + if not g.is_authenticated: + abort(401, message="You are not logged in.") + + class UserEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, User): @@ -38,7 +43,9 @@ class UserResource(Resource): """The UserResource is used for API access to users.""" def get(self, username): - """Returns the specified in JSON format.""" + """Returns the specified user in JSON format.""" + + _assert_is_authenticated() try: conn = ldap.get_connection() @@ -77,6 +84,8 @@ class GroupResource(Resource): A JSON string and HTTP status code. """ + _assert_is_authenticated() + try: conn = ldap.get_connection() except: @@ -109,6 +118,8 @@ class GroupResource(Resource): json : str , status : int A JSON string and HTTP status code. """ + + _assert_is_authenticated() group_dict = request.get_json() if not isinstance(group_dict, dict): @@ -172,6 +183,8 @@ class GroupResource(Resource): A JSON string and HTTP status code. """ + _assert_is_authenticated() + try: conn = ldap.get_connection() except: @@ -212,6 +225,8 @@ class GroupMemberResource(Resource): error message and HTTP error code are returned. """ + _assert_is_authenticated() + try: conn = ldap.get_connection() except: @@ -262,6 +277,8 @@ class GroupMemberResource(Resource): error message and HTTP error code are returned. """ + _assert_is_authenticated() + try: conn = ldap.get_connection() except: