feat: Add initial code drop

This commit is contained in:
Nathan Woodburn 2025-03-27 14:50:35 +11:00
parent 1545667105
commit ffc69c7b9f
Signed by: nathanwoodburn
GPG Key ID: 203B000478AD0EF1
9 changed files with 451 additions and 4 deletions

12
.env.example Normal file
View File

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

3
.gitignore vendored
View File

@ -4,3 +4,6 @@ __pycache__/
.env
.vs/
.venv/
emergency.md
flask_session/

View File

@ -1,3 +1,55 @@
# python-webserver-template
# Emergency Access System
Python3 website template including git actions
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:

View File

@ -1,4 +1,8 @@
flask
gunicorn
requests
python-dotenv
python-dotenv
yubico-client
Flask-Session
markdown
pygments

134
server.py
View File

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

120
templates/emergency.html Normal file
View File

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

View File

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

63
templates/login.html Normal file
View File

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

55
update.py Normal file
View File

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