Initial commit

This commit is contained in:
Shirkanesi 2024-12-05 22:56:57 +01:00
commit 1df066fe6c
9 changed files with 787 additions and 0 deletions

7
.env.example Normal file
View File

@ -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

304
.gitignore vendored Normal file
View File

@ -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

145
app.py Normal file
View File

@ -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)

5
requirements.txt Normal file
View File

@ -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

100
static/main.css Normal file
View File

@ -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;
}

47
static/reset.css Normal file
View File

@ -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;
}

17
templates/layout.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<link rel="stylesheet" href="{{ url_for('static', filename='reset.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='main.css') }}" />
{% block head %}
<title>{% block title %}{% endblock %}</title>
{% endblock %}
</head>
<body>
<div id="content">{% block content %}{% endblock %}</div>
<div id="footer">
{% block footer %}
{% endblock %}
</div>
</body>
</html>

16
templates/login.html Normal file
View File

@ -0,0 +1,16 @@
{% extends 'layout.html' %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="center-box">
<div class="center-box-content m-2 center">
<h1>Login</h1>
<form method="POST" class="form-control m-auto">
<label>Username:</label>
<input type="text" name="username" required>
<label>Password:</label>
<input type="password" name="password" required>
<button type="submit" class="btn btn-success">Login</button>
</form>
</div>
</div>
{% endblock %}

146
templates/profile.html Normal file
View File

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="de">
<head>
<title>{{ user_attrs.cn }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='main.css') }}" />
</head>
<body>
{% for category, mesg in get_flashed_messages(with_categories=True) %}
<div class="flash-message flash-message-{{ category }}">{{ mesg }}</div>
{% endfor %}
<h2>Kombilösen: {{ user_attrs.cn }}</h2>
<a href="/logout" class="btn">Logout</a><br><br>
<div class="flex flex-row">
<div>
<form method="POST" class="form-control">
<table class="table-items-top">
<tr>
<td>
Benutzername:
</td>
<td>
{{ user_attrs.uid }}
</td>
</tr>
<tr>
<td>
Anzeigename:
</td>
<td>
<input type="text" name="cn" value="{{ user_attrs.cn }}">
</td>
</tr>
<tr>
<td>
Nachname:
</td>
<td>
<input type="text" name="sn" value="{{ user_attrs.sn }}">
</td>
</tr>
<tr>
<td>
<label>Vorname:</label>
</td>
<td>
<input type="text" name="givenName" value="{{ user_attrs.givenName }}">
</td>
</tr>
<tr>
<td>
<label>Mail:</label>
</td>
<td>
{{ user_attrs.mail }}
</td>
</tr>
<tr>
<td>
<label>SIP Password:</label>
</td>
<td>
<input type="password" name="sipPassword" value="{{ user_attrs.sipPassword }}" readonly>
<button class="btn btn-warning" onclick="show_sip_password(); e.preventDefault()" type="button">Anzeigen</button>
<button class="btn btn-danger" onclick="regenerate_sip_password(); e.preventDefault()" type="button">Neu generieren</button>
</td>
</tr>
<tr>
<td>
<label>Zugriff auf interne Daten:</label>
</td>
<td>
{{ '✅' if user_attrs.innerCircle == True else '❌' }}
</td>
</tr>
<tr>
<td>
<label>Geschützter Account:</label>
</td>
<td>
{{ '✅' if user_attrs.protectedAccount == True else '❌' }}
</td>
</tr>
</table>
<button type="submit" class="btn btn-success">Speichern</button>
<h3>Passwort ändern</h3>
<table class="table-items-top">
<tr>
<td>
<label>Neues Passwort:</label>
</td>
<td>
<input type="password" name="password">
</td>
</tr>
<tr>
<td>
<label>Neues Passwort (wiederholen):</label>
</td>
<td>
<input type="password" name="password_repeat">
</td>
</tr>
</table>
<button type="submit" class="btn btn-success">Speichern</button>
<h3>SSH Public Keys</h3>
<textarea name="sshPublicKey" cols="120" rows="8" wrap="off">
{%- for key in user_attrs.sshPublicKey -%}
{{- key + '\n' }}
{%- endfor -%}
</textarea>
<br>
<button type="submit" class="btn btn-success">Speichern</button>
</form>
</div>
<div>
<h3>Gruppen</h3>
<ul>
{% for group in groups %}
<li>{{ group }}</li>
{% endfor %}
</ul>
</div>
</div>
</body>
<script>
function show_sip_password() {
document.getElementsByName("sipPassword")[0].type = document.getElementsByName("sipPassword")[0].type === "password" ? "text" : "password"
}
function regenerate_sip_password() {
document.getElementsByName("sipPassword")[0].value = generatePassword(12)
}
function generatePassword (
length = 20,
characters = '0123456789abcdefghijklmnopqrstuvwxyz'
) {
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
.map((x) => characters[x % characters.length])
.join('')
}
</script>
</html>