From 1df066fe6c9e515094ab78738ad7a582884b7597 Mon Sep 17 00:00:00 2001 From: Shirkanesi Date: Thu, 5 Dec 2024 22:56:57 +0100 Subject: [PATCH] Initial commit --- .env.example | 7 + .gitignore | 304 +++++++++++++++++++++++++++++++++++++++++ app.py | 145 ++++++++++++++++++++ requirements.txt | 5 + static/main.css | 100 ++++++++++++++ static/reset.css | 47 +++++++ templates/layout.html | 17 +++ templates/login.html | 16 +++ templates/profile.html | 146 ++++++++++++++++++++ 9 files changed, 787 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/main.css create mode 100644 static/reset.css create mode 100644 templates/layout.html create mode 100644 templates/login.html create mode 100644 templates/profile.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..adb8adb --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +LDAP_SERVER=ldaps://ldap.example.com:636 +USER_DN="ou=accounts,dc=example,dc=com" +GROUP_DN="ou=groups,dc=example,dc=com" +LDAP_SERVICE_USER="uid=self-service-portal,ou=service,ou=accounts,dc=example,dc=com" +LDAP_SERVICE_PASSWORD="yoursupersecurepassword" +FLASK_SECRET_KEY=your_flask_secret +JWT_SECRET=your_jwt_secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d76b10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,304 @@ +# Created by https://www.toptal.com/developers/gitignore/api/pycharm+all,python,vim,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all,python,vim,visualstudiocode + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/pycharm+all,python,vim,visualstudiocode diff --git a/app.py b/app.py new file mode 100644 index 0000000..e2a1b5b --- /dev/null +++ b/app.py @@ -0,0 +1,145 @@ +import base64 +import hashlib +import os +from flask import Flask, request, redirect, url_for, render_template, flash +from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user +from ldap3 import Server, Connection, MODIFY_REPLACE +import jwt +from datetime import datetime, timedelta +from functools import wraps + + +# Flask setup +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretflaskkey") + +# Flask-Login setup +login_manager = LoginManager() +login_manager.init_app(app) + +# LDAP setup +LDAP_SERVER = os.getenv("LDAP_SERVER") +USER_DN = os.getenv("USER_DN") +GROUP_DN = os.getenv("GROUP_DN") +LDAP_SERVICE_USER = os.getenv("LDAP_SERVICE_USER") +LDAP_SERVICE_PASSWORD = os.getenv("LDAP_SERVICE_PASSWORD") + +# JWT setup +JWT_SECRET = os.getenv("JWT_SECRET") +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_SECONDS = 3600 + +# User model +class User(UserMixin): + def __init__(self, id, dn): + self.id = id + self.dn = dn + +@login_manager.user_loader +def load_user(user_id): + return User(user_id, f"uid={user_id},{USER_DN}") + +# JWT utility functions +def generate_jwt(payload): + expiration = datetime.utcnow() + timedelta(seconds=JWT_EXPIRATION_SECONDS) + payload.update({"exp": expiration}) + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + +def decode_jwt(token): + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + +# Decorator for JWT authentication +def jwt_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + token = request.cookies.get("jwt") + if not token: + flash("You must log in to access this page.", "error") + return redirect(url_for("login")) + payload = decode_jwt(token) + if not payload: + flash("Session expired or invalid. Please log in again.", "error") + return redirect(url_for("login")) + request.user = payload.get("username") + return f(*args, **kwargs) + return decorated_function + +# Routes +@app.route("/", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] + user_dn = f"uid={username},{USER_DN}" + server = Server(LDAP_SERVER) + conn = Connection(server, user=user_dn, password=password) + if conn.bind(): + login_user(User(username, user_dn)) + token = generate_jwt({"username": username}) + response = redirect(url_for("profile")) + response.set_cookie("jwt", token, httponly=True, secure=True) + return response + flash("Invalid credentials", "error") + return render_template("login.html") + +@app.route("/logout") +@login_required +def logout(): + logout_user() + response = redirect(url_for("login")) + response.delete_cookie("jwt") + return response + +@app.route("/profile", methods=["GET", "POST"]) +@login_required +@jwt_required +def profile(): + username = request.user + user_dn = f"uid={username},{USER_DN}" + server = Server(LDAP_SERVER) + + # Connect as the service account + service_conn = Connection(server, user=LDAP_SERVICE_USER, password=LDAP_SERVICE_PASSWORD, auto_bind=True) + + service_conn.search(user_dn, "(objectClass=klUser)", attributes=["*"]) + user_attrs = service_conn.entries[0] + + if request.method == "POST": + if "protectedAccount" in user_attrs and user_attrs.protectedAccount == True: + flash("User has protectedAccount: TRUE. Cannot edit!", "error") + else: + updates = { + "sn": request.form["sn"], + "cn": request.form["cn"], + "givenName": request.form["givenName"], + "sipPassword": request.form["sipPassword"], + } + for key, value in updates.items(): + service_conn.modify(user_dn, {key: [(MODIFY_REPLACE, [value])]}) + ssh_keys = request.form.get('sshPublicKey', '').split('\n') + ssh_keys = map(str.strip, ssh_keys) + ssh_keys = list(set(filter(lambda x: not x.startswith('#') and len(x) > 0, ssh_keys))) + service_conn.modify(user_dn, {"sshPublicKey": [(MODIFY_REPLACE, ssh_keys)]}) + if "password" in request.form and request.form["password"].strip() and "password_repeat" in request.form and request.form["password_repeat"].strip(): + if request.form["password"] == request.form["password_repeat"]: + hashed_password = "{SHA}" + base64.b64encode(hashlib.sha1(request.form["password"].encode()).digest()).decode() + service_conn.modify(user_dn, {"userPassword": [(MODIFY_REPLACE, [hashed_password])]}) + else: + flash("Passwords do not match", "error") + flash("Profile updated successfully", "success") + + service_conn.search(user_dn, "(objectClass=klUser)", attributes=["*"]) + user_attrs = service_conn.entries[0] + service_conn.search(GROUP_DN, f"(member={user_dn})", attributes=["cn"]) + groups = [entry.cn.value for entry in service_conn.entries] + return render_template("profile.html", user_attrs=user_attrs, groups=groups) + +if __name__ == "__main__": + app.run(debug=True, host="127.0.0.1", port=5000) + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5555dc9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.0 # Flask framework +Flask-Login==0.6.3 # User session management +ldap3==2.9 # LDAP connection and query handling +PyJWT==2.8.0 # JWT generation and verification +python-dotenv diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..4165483 --- /dev/null +++ b/static/main.css @@ -0,0 +1,100 @@ +html { + background: white; + color: black; + font-family: 'Roboto Medium', sans-serif; +} + +input, button, textarea { + background: none; + border: none; +} + +input, textarea { + background-color: white; + border: 1px solid black; +} + +.center-box { + background: none; + position: relative; + width: 60vw; + margin: auto; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; +} + +.center-box-content { +} + +form.form-control { + width: 60%; + text-align: left; +} + +form.form-control label, form.form-control input, .form-control textarea { + width: 100%; +} + +form.form-control input, form.form-control button, .form-control textarea { + margin-bottom: 1em; + border-radius: 0.5em; +} + + +.m-1 { + margin: 1em; +} +.m-2 { + margin: 2em; +} + +.m-auto { + margin: auto; +} + +.flex { + display: flex; +} + +.flex-row { + flex-direction: row; +} + +.center { + text-align: center; +} + +.btn { + padding: 0.5em; + background-color: cornflowerblue; + border-radius: 0.5em; +} + +.btn-success { + background-color: darkgreen; + color: white; +} + +.btn-warning { + background-color: orange; + color: black; +} + +.btn-danger { + background-color: darkred; + color: white; +} + +a.btn { + text-decoration: none; + color: inherit; +} + +.table-items-top td { + vertical-align: top; +} +.table-items-middle td { + vertical-align: middle; +} diff --git a/static/reset.css b/static/reset.css new file mode 100644 index 0000000..ac7a7a3 --- /dev/null +++ b/static/reset.css @@ -0,0 +1,47 @@ +/* 1. Use a more-intuitive box-sizing model */ +*, *::before, *::after { + box-sizing: border-box; +} + +/* 2. Remove default margin */ +* { + margin: 0; +} + +body { + /* 3. Add accessible line-height */ + line-height: 1.5; + /* 4. Improve text rendering */ + -webkit-font-smoothing: antialiased; +} + +/* 5. Improve media defaults */ +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +/* 6. Inherit fonts for form controls */ +input, button, textarea, select { + font: inherit; +} + +/* 7. Avoid text overflows */ +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} + +/* 8. Improve line wrapping */ +p { + text-wrap: pretty; +} +h1, h2, h3, h4, h5, h6 { + text-wrap: balance; +} + +/* + 9. Create a root stacking context +*/ +#root, #__next { + isolation: isolate; +} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..4211a23 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,17 @@ + + + + + + {% block head %} + {% block title %}{% endblock %} + {% endblock %} + + +
{% block content %}{% endblock %}
+ + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a32bce2 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,16 @@ +{% extends 'layout.html' %} +{% block title %}Login{% endblock %} +{% block content %} +
+
+

Login

+
+ + + + + +
+
+
+{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..dacb25e --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,146 @@ + + + + {{ user_attrs.cn }} + + + + {% for category, mesg in get_flashed_messages(with_categories=True) %} +
{{ mesg }}
+ {% endfor %} +

Kombilösen: {{ user_attrs.cn }}

+ Logout

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Benutzername: + + {{ user_attrs.uid }} +
+ Anzeigename: + + +
+ Nachname: + + +
+ + + +
+ + + {{ user_attrs.mail }} +
+ + + + + +
+ + + {{ '✅' if user_attrs.innerCircle == True else '❌' }} +
+ + + {{ '✅' if user_attrs.protectedAccount == True else '❌' }} +
+ + +

Passwort ändern

+ + + + + + + + + +
+ + + +
+ + + +
+ + +

SSH Public Keys

+ +
+ +
+ +
+
+

Gruppen

+
    + {% for group in groups %} +
  • {{ group }}
  • + {% endfor %} +
+
+
+ + + + +