parent
1545667105
commit
ffc69c7b9f
12
.env.example
Normal file
12
.env.example
Normal 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
3
.gitignore
vendored
@ -4,3 +4,6 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.vs/
|
.vs/
|
||||||
.venv/
|
.venv/
|
||||||
|
emergency.md
|
||||||
|
|
||||||
|
flask_session/
|
||||||
|
56
README.md
56
README.md
@ -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:
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
flask
|
flask
|
||||||
gunicorn
|
gunicorn
|
||||||
requests
|
requests
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
yubico-client
|
||||||
|
Flask-Session
|
||||||
|
markdown
|
||||||
|
pygments
|
134
server.py
134
server.py
@ -9,16 +9,43 @@ from flask import (
|
|||||||
render_template,
|
render_template,
|
||||||
send_from_directory,
|
send_from_directory,
|
||||||
send_file,
|
send_file,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
)
|
)
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import dotenv
|
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()
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
app = Flask(__name__)
|
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):
|
def find(name, path):
|
||||||
@ -74,8 +101,65 @@ def wellknown(path):
|
|||||||
# region Main routes
|
# region Main routes
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
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>")
|
@app.route("/<path:path>")
|
||||||
def catch_all(path: str):
|
def catch_all(path: str):
|
||||||
@ -102,6 +186,54 @@ def catch_all(path: str):
|
|||||||
# endregion
|
# 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
|
# region Error Catching
|
||||||
# 404 catch all
|
# 404 catch all
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
|
120
templates/emergency.html
Normal file
120
templates/emergency.html
Normal 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>
|
@ -13,6 +13,12 @@
|
|||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="centre">
|
<div class="centre">
|
||||||
<h1>Nathan.Woodburn/</h1>
|
<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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
63
templates/login.html
Normal file
63
templates/login.html
Normal 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
55
update.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user