From ffc69c7b9f44f5b1b087f53d7ac99ccfb90d2803 Mon Sep 17 00:00:00 2001 From: Nathan Woodburn <github@nathan.woodburn.au> Date: Thu, 27 Mar 2025 14:50:35 +1100 Subject: [PATCH] feat: Add initial code drop --- .env.example | 12 ++++ .gitignore | 3 + README.md | 56 +++++++++++++++- requirements.txt | 6 +- server.py | 134 ++++++++++++++++++++++++++++++++++++++- templates/emergency.html | 120 +++++++++++++++++++++++++++++++++++ templates/index.html | 6 ++ templates/login.html | 63 ++++++++++++++++++ update.py | 55 ++++++++++++++++ 9 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 templates/emergency.html create mode 100644 templates/login.html create mode 100644 update.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f99a776 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Flask secret key for session management +SECRET_KEY=your_secret_key_here + +# Yubico API credentials (get from https://upgrade.yubico.com/getapikey/) +YUBICO_CLIENT_ID=your_client_id +YUBICO_SECRET_KEY=your_secret_key + +# Your YubiKey ID (first 12 characters of any OTP generated by your YubiKey) +YUBIKEY_ID=ccccccxxxxxx + +# Webhook secret for updating emergency.md +WEBHOOK_SECRET=your_webhook_secret_here diff --git a/.gitignore b/.gitignore index 7d847cc..6bb0493 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ __pycache__/ .env .vs/ .venv/ +emergency.md + +flask_session/ diff --git a/README.md b/README.md index 2f77bf4..e86b81e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,55 @@ -# python-webserver-template +# Emergency Access System -Python3 website template including git actions \ No newline at end of file +A secure emergency information access system using YubiKey authentication. This application allows authorized users to access critical emergency information stored in Markdown format. + +## Features + +- YubiKey authentication for secure access +- Markdown rendering with syntax highlighting +- Webhook for remote updates of emergency information +- Mobile-friendly interface + +## Setup + +1. Clone this repository +2. Install dependencies: + ``` + pip install -r requirements.txt + ``` +3. Create a `.env` file based on `.env.example`: + ``` + cp .env.example .env + ``` +4. Edit the `.env` file with your settings + +### YubiKey Configuration + +1. Get your YubiKey API credentials from https://upgrade.yubico.com/getapikey/ +2. Add your client ID and secret key to the `.env` file +3. Determine your YubiKey ID (first 12 characters of any OTP generated by your YubiKey) +4. Add the YubiKey ID to the `.env` file + +### Webhook Secret Generation + +The webhook requires a strong secret to secure remote updates. Use one of these methods to generate a secure secret: + +#### Option 1: Using Python + +```python +import secrets +print(secrets.token_hex(32)) # Generates a 64-character hex string +``` + +#### Option 2: Using OpenSSL + +```bash +openssl rand -hex 32 +``` + +#### Option 3: Using /dev/urandom (Linux/Mac) + +```bash +head -c 32 /dev/urandom | xxd -p -c 32 +``` + +Add the generated secret to your `.env` file: diff --git a/requirements.txt b/requirements.txt index 8cf7fcb..53c61cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ flask gunicorn requests -python-dotenv \ No newline at end of file +python-dotenv +yubico-client +Flask-Session +markdown +pygments \ No newline at end of file diff --git a/server.py b/server.py index 240a1a8..d7a1224 100644 --- a/server.py +++ b/server.py @@ -9,16 +9,43 @@ from flask import ( render_template, send_from_directory, send_file, + session, + url_for, ) import os import json import requests from datetime import datetime import dotenv +import markdown +import markdown.extensions.fenced_code +from flask_session import Session +from yubico_client import Yubico +from functools import wraps +import hmac +import hashlib dotenv.load_dotenv() app = Flask(__name__) +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24)) +app.config["SESSION_TYPE"] = "filesystem" +app.config["SESSION_PERMANENT"] = False +Session(app) + +# Yubikey settings +YUBICO_CLIENT_ID = os.getenv("YUBICO_CLIENT_ID") +YUBICO_SECRET_KEY = os.getenv("YUBICO_SECRET_KEY") +YUBIKEY_ID = os.getenv("YUBIKEY_ID") # The first 12 characters of your YubiKey OTP + +# Authentication function +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('authenticated'): + return redirect(url_for('login', next=request.url)) + return f(*args, **kwargs) + return decorated_function def find(name, path): @@ -74,8 +101,65 @@ def wellknown(path): # region Main routes @app.route("/") def index(): - return render_template("index.html") + return render_template("index.html", authenticated=session.get('authenticated', False)) +# Login and authentication routes +@app.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + otp = request.form.get("otp", "") + + # Verify the first 12 characters of the OTP match the expected YubiKey ID + if not otp or len(otp) < 12 or otp[:12] != YUBIKEY_ID: + error = "Invalid YubiKey OTP" + else: + try: + # Initialize Yubico client + client = Yubico(YUBICO_CLIENT_ID, YUBICO_SECRET_KEY) + + # Verify the OTP with Yubico servers + verification = client.verify(otp) + if verification: + session['authenticated'] = True + next_url = request.args.get('next', url_for('emergency')) + return redirect(next_url) + else: + error = "YubiKey authentication failed" + except Exception as e: + error = f"Authentication error: {str(e)}" + + return render_template("login.html", error=error) + +@app.route("/logout") +def logout(): + session.pop('authenticated', None) + return redirect(url_for('index')) + +@app.route("/emergency") +@login_required +def emergency(): + # Check if emergency.md exists + emergency_path = os.path.join(os.path.dirname(__file__), "emergency.md") + + if os.path.exists(emergency_path): + with open(emergency_path, 'r') as f: + emergency_content = f.read() + # Convert markdown to HTML with enhanced extensions + emergency_html = markdown.markdown( + emergency_content, + extensions=[ + 'fenced_code', + 'codehilite', + 'tables', + 'nl2br', + 'sane_lists' # Ensures proper list rendering + ] + ) + return render_template("emergency.html", content=emergency_html) + else: + return render_template("emergency.html", + content="<p>No emergency information available.</p>") @app.route("/<path:path>") def catch_all(path: str): @@ -102,6 +186,54 @@ def catch_all(path: str): # endregion +# region Webhook +@app.route("/webhook/update", methods=["POST"]) +def webhook_update(): + # Get the webhook secret from environment + webhook_secret = os.getenv("WEBHOOK_SECRET") + if not webhook_secret: + return jsonify({"error": "Webhook not configured"}), 500 + + # Verify X-Webhook-Signature header + signature = request.headers.get("X-Webhook-Signature") + if not signature: + return jsonify({"error": "Missing signature"}), 401 + + # Get request body + payload = request.get_data() + + # Verify signature + expected_signature = hmac.new( + webhook_secret.encode(), + payload, + hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(signature, expected_signature): + return jsonify({"error": "Invalid signature"}), 401 + + # Process the update + try: + data = request.json + if not data or not isinstance(data, dict) or "content" not in data: + return jsonify({"error": "Invalid payload format"}), 400 + + emergency_content = data["content"] + emergency_path = os.path.join(os.path.dirname(__file__), "emergency.md") + + # Write the new content to the file + with open(emergency_path, "w") as f: + f.write(emergency_content) + + return jsonify({"success": True, "message": "Emergency content updated"}), 200 + + except Exception as e: + app.logger.error(f"Webhook error: {str(e)}") + return jsonify({"error": f"Update failed: {str(e)}"}), 500 + +# endregion + + # region Error Catching # 404 catch all @app.errorhandler(404) diff --git a/templates/emergency.html b/templates/emergency.html new file mode 100644 index 0000000..6fb64ae --- /dev/null +++ b/templates/emergency.html @@ -0,0 +1,120 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Emergency Information - Nathan.Woodburn/</title> + <link rel="icon" href="/assets/img/favicon.png" type="image/png"> + <link rel="stylesheet" href="/assets/css/index.css"> + <!-- Remove highlight.js as it's not needed with Pygments --> + <style> + .emergency-content { + max-width: 800px; + margin: 0 auto; + text-align: left; + padding: 20px; + background-color: #111; + border-radius: 8px; + } + .controls { + margin-top: 20px; + } + /* Additional styling for code blocks */ + .codehilite { + padding: 0; + margin: 1em 0; + border-radius: 5px; + overflow: auto; + } + .codehilite pre { + padding: 10px; + margin: 0; + background-color: #1e1e1e; + border-radius: 5px; + overflow-x: auto; + } + /* Fix code display in dark theme */ + .codehilite .k { color: #569cd6; } /* Keyword */ + .codehilite .s, .codehilite .s1, .codehilite .s2 { color: #ce9178; } /* String */ + .codehilite .c, .codehilite .c1 { color: #6a9955; } /* Comment */ + .codehilite .n { color: #dcdcdc; } /* Name */ + .codehilite .o { color: #d4d4d4; } /* Operator */ + .codehilite .p { color: #d4d4d4; } /* Punctuation */ + + /* YAML-specific styles */ + .codehilite .l { color: #b5cea8; } /* Literals */ + .codehilite .kn { color: #569cd6; } /* Key Name (YAML keys) */ + + /* Styling for lists */ + .emergency-content ol { + list-style-type: decimal; + padding-left: 30px; + margin: 15px 0; + } + + .emergency-content ol ol { + list-style-type: lower-alpha; + } + + .emergency-content ol ol ol { + list-style-type: lower-roman; + } + + .emergency-content li { + margin: 5px 0; + line-height: 1.5; + } + + /* Adding some spacing between list items for better readability */ + .emergency-content li + li { + margin-top: 8px; + } + + /* Styling for unordered lists as well */ + .emergency-content ul { + list-style-type: disc; + padding-left: 30px; + margin: 15px 0; + } + + .emergency-content ul ul { + list-style-type: circle; + } + + .emergency-content ul ul ul { + list-style-type: square; + } + </style> +</head> + +<body> + <div class="spacer"></div> + <div class="centre"> + <h1>Emergency Information</h1> + + <div class="emergency-content"> + {{ content|safe }} + </div> + + <div class="controls"> + <p><a href="/">Back to Home</a> | <a href="/logout">Logout</a></p> + </div> + </div> + + <!-- Script to make all content links open in a new tab --> + <script> + document.addEventListener('DOMContentLoaded', function() { + // Select all links in the emergency content + const contentLinks = document.querySelectorAll('.emergency-content a'); + + // Add target="_blank" and rel="noopener" (for security) to each link + contentLinks.forEach(link => { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + }); + }); + </script> +</body> + +</html> diff --git a/templates/index.html b/templates/index.html index bb349f8..69fdc6c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -13,6 +13,12 @@ <div class="spacer"></div> <div class="centre"> <h1>Nathan.Woodburn/</h1> + {% if authenticated %} + <p><a href="/emergency">Access Emergency Information</a></p> + <p><a href="/logout">Logout</a></p> + {% else %} + <p><a href="/login">Login with YubiKey</a></p> + {% endif %} </div> </body> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..5df189c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,63 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Login - Nathan.Woodburn/</title> + <link rel="icon" href="/assets/img/favicon.png" type="image/png"> + <link rel="stylesheet" href="/assets/css/index.css"> + <style> + .error { + color: #ff5555; + margin-top: 10px; + } + input { + padding: 8px; + margin: 15px 0; + background-color: #222; + color: white; + border: 1px solid #555; + border-radius: 4px; + } + button { + padding: 8px 15px; + background-color: #444; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + } + button:hover { + background-color: #555; + } + form { + margin-top: 20px; + } + </style> +</head> + +<body> + <div class="spacer"></div> + <div class="centre"> + <h1>YubiKey Authentication</h1> + <p>Please insert your YubiKey and press it to authenticate.</p> + + {% if error %} + <div class="error">{{ error }}</div> + {% endif %} + + <form method="POST" action="/login"> + <div> + <input type="text" id="otp" name="otp" placeholder="Press your YubiKey..." autofocus> + </div> + <div> + <button type="submit">Authenticate</button> + </div> + </form> + + <p><a href="/">Back to Home</a></p> + </div> +</body> + +</html> diff --git a/update.py b/update.py new file mode 100644 index 0000000..c41bd09 --- /dev/null +++ b/update.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Example script to update emergency.md content via webhook. + +Usage: + python webhook_example.py <webhook_url> <webhook_secret> <markdown_file> + +Example: + python webhook_example.py https://emergency.example.com mysecret ./new_emergency.md +""" + +import sys +import hmac +import hashlib +import requests +import json + +def update_emergency_content(webhook_url, webhook_secret, content_file): + # Read the content from the file + with open(content_file, 'r') as f: + content = f.read() + + # Prepare the payload + payload = json.dumps({"content": content}) + + # Calculate the signature + signature = hmac.new( + webhook_secret.encode(), + payload.encode(), + hashlib.sha256 + ).hexdigest() + + # Set headers + headers = { + "Content-Type": "application/json", + "X-Webhook-Signature": signature + } + + # Send the request + response = requests.post(f"{webhook_url}/webhook/update", data=payload, headers=headers) + + # Print the result + print(f"Status code: {response.status_code}") + print(f"Response: {response.text}") + +if __name__ == "__main__": + if len(sys.argv) != 4: + print(f"Usage: {sys.argv[0]} <webhook_url> <webhook_secret> <markdown_file>") + sys.exit(1) + + webhook_url = sys.argv[1] + webhook_secret = sys.argv[2] + content_file = sys.argv[3] + + update_emergency_content(webhook_url, webhook_secret, content_file)