From 272e2d6090b9dba35f789b6648f44558139632a4 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 12 Feb 2026 12:10:30 +0100 Subject: [PATCH] first commit --- README.md | 0 auth-app/Dockerfile | 12 +++ auth-app/app.py | 178 ++++++++++++++++++++++++++++++++++ auth-app/requirements.txt | 3 + auth-app/templates/base.html | 58 +++++++++++ auth-app/templates/login.html | 20 ++++ auth-app/templates/users.html | 70 +++++++++++++ docker-compose.yml | 59 +++++++++++ 8 files changed, 400 insertions(+) create mode 100644 README.md create mode 100644 auth-app/Dockerfile create mode 100644 auth-app/app.py create mode 100644 auth-app/requirements.txt create mode 100644 auth-app/templates/base.html create mode 100644 auth-app/templates/login.html create mode 100644 auth-app/templates/users.html create mode 100644 docker-compose.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/auth-app/Dockerfile b/auth-app/Dockerfile new file mode 100644 index 0000000..5076e26 --- /dev/null +++ b/auth-app/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "app:app"] diff --git a/auth-app/app.py b/auth-app/app.py new file mode 100644 index 0000000..45736bf --- /dev/null +++ b/auth-app/app.py @@ -0,0 +1,178 @@ +import os +import sqlite3 +from functools import wraps + +import bcrypt as bc +from flask import ( + Flask, + flash, + redirect, + render_template, + request, + session, + url_for, +) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", "default-secret-key") + +ADMIN_USER = os.environ.get("ADMIN_USER", "admin") +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "changeme") +DB_PATH = os.environ.get("DB_PATH", "/data/users.db") +HTPASSWD_PATH = os.environ.get("HTPASSWD_PATH", "/auth/htpasswd") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + os.makedirs(os.path.dirname(HTPASSWD_PATH), exist_ok=True) + conn = get_db() + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.commit() + conn.close() + + +def hash_password(password): + """Hash password with bcrypt, using $2y$ identifier for htpasswd compatibility.""" + hashed = bc.hashpw(password.encode("utf-8"), bc.gensalt(rounds=12)) + return hashed.decode("utf-8").replace("$2b$", "$2y$", 1) + + +def sync_htpasswd(): + """Regenerate htpasswd file from all users in SQLite.""" + conn = get_db() + users = conn.execute("SELECT username, password_hash FROM users").fetchall() + conn.close() + with open(HTPASSWD_PATH, "w") as f: + for user in users: + f.write(f"{user['username']}:{user['password_hash']}\n") + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("logged_in"): + return redirect(url_for("login")) + return f(*args, **kwargs) + + return decorated + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + if username == ADMIN_USER and password == ADMIN_PASSWORD: + session["logged_in"] = True + return redirect(url_for("users")) + flash("Ungueltige Anmeldedaten.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.pop("logged_in", None) + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def users(): + conn = get_db() + user_list = conn.execute( + "SELECT id, username, created_at FROM users ORDER BY username" + ).fetchall() + conn.close() + return render_template("users.html", users=user_list) + + +@app.route("/add", methods=["POST"]) +@login_required +def add_user(): + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + flash("Benutzername und Passwort sind erforderlich.", "error") + return redirect(url_for("users")) + + if len(password) < 6: + flash("Passwort muss mindestens 6 Zeichen lang sein.", "error") + return redirect(url_for("users")) + + password_h = hash_password(password) + + conn = get_db() + try: + conn.execute( + "INSERT INTO users (username, password_hash) VALUES (?, ?)", + (username, password_h), + ) + conn.commit() + sync_htpasswd() + flash(f'Benutzer "{username}" wurde erstellt.', "success") + except sqlite3.IntegrityError: + flash(f'Benutzer "{username}" existiert bereits.', "error") + finally: + conn.close() + + return redirect(url_for("users")) + + +@app.route("/password/", methods=["POST"]) +@login_required +def change_password(user_id): + password = request.form.get("password", "") + + if not password or len(password) < 6: + flash("Passwort muss mindestens 6 Zeichen lang sein.", "error") + return redirect(url_for("users")) + + password_h = hash_password(password) + + conn = get_db() + conn.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (password_h, user_id), + ) + conn.commit() + conn.close() + sync_htpasswd() + flash("Passwort wurde geaendert.", "success") + return redirect(url_for("users")) + + +@app.route("/delete/", methods=["POST"]) +@login_required +def delete_user(user_id): + conn = get_db() + user = conn.execute( + "SELECT username FROM users WHERE id = ?", (user_id,) + ).fetchone() + if user: + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + sync_htpasswd() + flash(f'Benutzer "{user["username"]}" wurde geloescht.', "success") + conn.close() + return redirect(url_for("users")) + + +# Initialize database and htpasswd on startup +init_db() +sync_htpasswd() diff --git a/auth-app/requirements.txt b/auth-app/requirements.txt new file mode 100644 index 0000000..340eecd --- /dev/null +++ b/auth-app/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +bcrypt>=4.0 +gunicorn>=21.0 diff --git a/auth-app/templates/base.html b/auth-app/templates/base.html new file mode 100644 index 0000000..8967549 --- /dev/null +++ b/auth-app/templates/base.html @@ -0,0 +1,58 @@ + + + + + + {% block title %}Docker Registry{% endblock %} + + + +
+

Docker Registry

+ {% if session.get('logged_in') %} + Abmelden + {% endif %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/auth-app/templates/login.html b/auth-app/templates/login.html new file mode 100644 index 0000000..77beaa9 --- /dev/null +++ b/auth-app/templates/login.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Anmelden - Docker Registry{% endblock %} +{% block content %} +
+
+

Anmelden

+
+
+ + +
+
+ + +
+ +
+
+
+{% endblock %} diff --git a/auth-app/templates/users.html b/auth-app/templates/users.html new file mode 100644 index 0000000..182565f --- /dev/null +++ b/auth-app/templates/users.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}Benutzerverwaltung - Docker Registry{% endblock %} +{% block content %} + +
+

Neuen Benutzer anlegen

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

Registry-Benutzer

+ {% if users %} + + + + + + + + + + + {% for user in users %} + + + + + + + {% endfor %} + +
BenutzernameErstellt amPasswort aendern
{{ user.username }}{{ user.created_at }} +
+ + +
+
+
+ +
+
+ {% else %} +

Noch keine Benutzer angelegt.

+ {% endif %} +
+ +
+

Nutzung

+

+ Nach dem Anlegen eines Benutzers kann sich dieser mit docker login {{ request.host }} anmelden + und Images pushen/pullen. +

+
+ +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d564336 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./data/caddy/data:/data + - ./data/caddy/config:/config + entrypoint: ["sh", "-c"] + command: + - | + echo '${DOMAIN} { + handle /v2/* { + reverse_proxy registry:5000 + } + handle { + reverse_proxy auth:5000 + } + }' | caddy run --adapter caddyfile --config - + depends_on: + - registry + - auth + networks: + - registry_net + + registry: + image: registry:2 + restart: unless-stopped + environment: + REGISTRY_AUTH: htpasswd + REGISTRY_AUTH_HTPASSWD_REALM: Docker Registry + REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd + REGISTRY_STORAGE_DELETE_ENABLED: "true" + volumes: + - ./data/registry:/var/lib/registry + - ./data/htpasswd:/auth:ro + networks: + - registry_net + + auth: + build: ./auth-app + restart: unless-stopped + environment: + ADMIN_USER: ${ADMIN_USER} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + SECRET_KEY: ${SECRET_KEY} + DB_PATH: /data/users.db + HTPASSWD_PATH: /auth/htpasswd + volumes: + - ./data/auth:/data + - ./data/htpasswd:/auth + networks: + - registry_net + +networks: + registry_net: