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
|
||||
.vs/
|
||||
.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
|
||||
gunicorn
|
||||
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,
|
||||
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
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="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
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