Compare commits
7 Commits
82668938af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 692e7fe689 | |||
| d36310f558 | |||
| 264ac3cf0b | |||
| 9d0762722d | |||
| b7a6199a17 | |||
| 536b98ab33 | |||
| 23432327e9 |
18
README.md
18
README.md
@@ -10,7 +10,11 @@ Create a file (e.g., called `run.sh`) with the following:
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
export FLASK_SERVE_DIR="/home/YOURUSER/workspace/somesite/build/html"
|
export FLASK_SERVE_DIR="/home/YOURUSER/workspace/somesite/build/html"
|
||||||
export FLASK_MOTHERSHIP="http://localhost:8888/api/staticshield"
|
export FLASK_MOTHERSHIP="http://localhost:8888/api/staticshield"
|
||||||
|
# Optional path to 403.html, 404.html to show on those errors; leave empty to use default messages
|
||||||
|
export FLASK_ERROR_PAGES_DIR=""
|
||||||
|
#export FLASK_ERROR_PAGES_DIR="/home/YOURUSER/workspace/errorpages/"
|
||||||
export FLASK_SESSION_COOKIE_NAME="staticshield"
|
export FLASK_SESSION_COOKIE_NAME="staticshield"
|
||||||
|
export FLASK_PERMANENT_SESSION_LIFETIME=7200
|
||||||
|
|
||||||
flask --app staticshield run
|
flask --app staticshield run
|
||||||
```
|
```
|
||||||
@@ -25,7 +29,7 @@ ruff check --fix --select I .
|
|||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
Create a virtualenv with flask, gunicorn:
|
Create a virtualenv with Flask and gunicorn to run it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example, create wherever you like
|
# Example, create wherever you like
|
||||||
@@ -35,7 +39,7 @@ cd /srv/venvs/staticshield
|
|||||||
python3 -m venv .
|
python3 -m venv .
|
||||||
source bin/activate
|
source bin/activate
|
||||||
|
|
||||||
# Optional if you don't have uv installed globally yet (you should ;) )
|
# Optional if you don't have uv installed globally yet (you should ;) - see https://docs.astral.sh/uv/ )
|
||||||
pip install uv
|
pip install uv
|
||||||
|
|
||||||
uv pip install -r requirements.in
|
uv pip install -r requirements.in
|
||||||
@@ -49,11 +53,14 @@ Description=staticshield web application
|
|||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=divault
|
User=change_me_to_user_the_app_runs_under
|
||||||
WorkingDirectory=/srv/staticshield
|
WorkingDirectory=/srv/staticshield
|
||||||
#StandardOutput=file:/srv/logs/staticshield.log
|
#StandardOutput=file:/srv/logs/staticshield.log
|
||||||
Environment=FLASK_SERVE_DIR="/srv/some_static_website/html"
|
Environment=FLASK_SERVE_DIR="/srv/some_static_website/html"
|
||||||
Environment=FLASK_MOTHERSHIP="https://api.example.com/api/staticshield"
|
Environment=FLASK_MOTHERSHIP="https://api.example.com/api/staticshield"
|
||||||
|
# Optional path to 403.html, 404.html to show on those errors; leave empty to use default messages
|
||||||
|
Environment=FLASK_ERROR_PAGES_DIR=""
|
||||||
|
#Environment=FLASK_ERROR_PAGES_DIR="/srv/shared/errorpages/"
|
||||||
Environment=FLASK_SESSION_COOKIE_NAME="staticshield"
|
Environment=FLASK_SESSION_COOKIE_NAME="staticshield"
|
||||||
# Max session length of 2h
|
# Max session length of 2h
|
||||||
Environment=FLASK_PERMANENT_SESSION_LIFETIME=7200
|
Environment=FLASK_PERMANENT_SESSION_LIFETIME=7200
|
||||||
@@ -132,6 +139,11 @@ server {
|
|||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Optional, when using the FLASK_ERROR_PAGES_DIR
|
||||||
|
# location /404images/ {
|
||||||
|
# alias /srv/shared/errorpages/;
|
||||||
|
# }
|
||||||
|
|
||||||
location /favicon.ico {
|
location /favicon.ico {
|
||||||
alias /srv/whatever/_static/favicon.ico;
|
alias /srv/whatever/_static/favicon.ico;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,53 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "staticshield"
|
||||||
|
version = "20250319a"
|
||||||
|
authors = [
|
||||||
|
{name = "Michiel Scholten", email = "michiel@diginaut.net"},
|
||||||
|
]
|
||||||
|
description= "Shield a static website by requiring a login flow through a third site/web application"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
keywords = ["security", "api", "automation", "authentication"]
|
||||||
|
license = {text = "MIT"}
|
||||||
|
classifiers = [
|
||||||
|
"Framework :: Flask",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: MIT License"
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"flask",
|
||||||
|
"flask-session"
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
{include-group = "lint"},
|
||||||
|
{include-group = "pub"},
|
||||||
|
{include-group = "test"}
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
]
|
||||||
|
lint = [
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
"bandit>=1.7.10",
|
||||||
|
]
|
||||||
|
# Publishing on PyPI
|
||||||
|
pub = [
|
||||||
|
"build",
|
||||||
|
"twine"
|
||||||
|
]
|
||||||
|
server = [
|
||||||
|
"gunicorn>=23.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
markers = [
|
markers = [
|
||||||
"network: mark a test that needs internet access",
|
"network: mark a test that needs internet access",
|
||||||
@@ -50,7 +100,7 @@ select = [
|
|||||||
"__init__.py" = ["F401"]
|
"__init__.py" = ["F401"]
|
||||||
|
|
||||||
[tool.ruff.lint.mccabe]
|
[tool.ruff.lint.mccabe]
|
||||||
max-complexity = 10
|
max-complexity = 12
|
||||||
|
|
||||||
[tool.ruff.lint.pycodestyle]
|
[tool.ruff.lint.pycodestyle]
|
||||||
max-doc-length = 180
|
max-doc-length = 180
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import urllib.request
|
|||||||
from logging.config import dictConfig
|
from logging.config import dictConfig
|
||||||
|
|
||||||
from flask import Flask, redirect, request, send_from_directory, session
|
from flask import Flask, redirect, request, send_from_directory, session
|
||||||
|
|
||||||
from flask_session import Session
|
from flask_session import Session
|
||||||
|
|
||||||
dictConfig({
|
dictConfig({
|
||||||
@@ -33,7 +32,8 @@ Session(app)
|
|||||||
# Verify the required configuration
|
# Verify the required configuration
|
||||||
# SERVE_DIR: Base dir of the files we want to serve; Flask will take care not to escape this dir
|
# SERVE_DIR: Base dir of the files we want to serve; Flask will take care not to escape this dir
|
||||||
# MOTHERSHIP: Mothership server and login-url, which will redirect here with a sessionstart/SEKRIT
|
# MOTHERSHIP: Mothership server and login-url, which will redirect here with a sessionstart/SEKRIT
|
||||||
config_vars = ['SERVE_DIR', 'MOTHERSHIP']
|
# ERROR_PAGES_DIR: (optional) Base dir of 403.html, 404.html to show as alternative to default short error message
|
||||||
|
config_vars = ['SERVE_DIR', 'MOTHERSHIP', 'ERROR_PAGES_DIR']
|
||||||
for config_var in config_vars:
|
for config_var in config_vars:
|
||||||
if config_var not in app.config:
|
if config_var not in app.config:
|
||||||
print(f'FLASK_{config_var} env var not set')
|
print(f'FLASK_{config_var} env var not set')
|
||||||
@@ -42,8 +42,25 @@ for config_var in config_vars:
|
|||||||
app.logger.info('Config env %s with value "%s"', config_var, app.config[config_var])
|
app.logger.info('Config env %s with value "%s"', config_var, app.config[config_var])
|
||||||
|
|
||||||
|
|
||||||
|
def handle_error_code(http_code):
|
||||||
|
"""Handle 403/404/whatever HTTP response; either with a simple default, or with custom file.
|
||||||
|
|
||||||
|
:param int http_code: HTTP status code to handle and return
|
||||||
|
"""
|
||||||
|
path = f'{http_code}.html'
|
||||||
|
if app.config['ERROR_PAGES_DIR']:
|
||||||
|
file_path = os.path.join(app.config['ERROR_PAGES_DIR'], path)
|
||||||
|
app.logger.debug(file_path)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
app.logger.info('Serving file %s', str(file_path))
|
||||||
|
# This takes a base directory and a path, and ensures that the path is contained in the directory, which makes it safe to accept user-provided paths.
|
||||||
|
return send_from_directory(app.config['ERROR_PAGES_DIR'], path), http_code
|
||||||
|
return 'Unable to set up session', http_code
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
@app.route('/<path:path>', methods=['GET', 'POST'])
|
@app.route('/<path:path>', methods=['GET', 'POST'])
|
||||||
def all_routes(path):
|
def all_routes(path = ''):
|
||||||
"""Intercept all routes and proxy it through a session.
|
"""Intercept all routes and proxy it through a session.
|
||||||
|
|
||||||
Loosely based on https://stackoverflow.com/a/20648053
|
Loosely based on https://stackoverflow.com/a/20648053
|
||||||
@@ -56,6 +73,8 @@ def all_routes(path):
|
|||||||
|
|
||||||
# The path we should have gotten back is of the format:
|
# The path we should have gotten back is of the format:
|
||||||
# /sessionstart/SEKRIT_TOKEN/<the_url_to_redirect_on_here_afterwards>
|
# /sessionstart/SEKRIT_TOKEN/<the_url_to_redirect_on_here_afterwards>
|
||||||
|
# or, if the request is denied by the mothership:
|
||||||
|
# /sessionstart/denied
|
||||||
secret_and_redirect = path.split('sessionstart/')[1]
|
secret_and_redirect = path.split('sessionstart/')[1]
|
||||||
secret_redirect_split = secret_and_redirect.split('/')
|
secret_redirect_split = secret_and_redirect.split('/')
|
||||||
secret = secret_redirect_split[0]
|
secret = secret_redirect_split[0]
|
||||||
@@ -63,6 +82,10 @@ def all_routes(path):
|
|||||||
if len(secret_redirect_split) > 1:
|
if len(secret_redirect_split) > 1:
|
||||||
redirect_path = '/'.join(secret_redirect_split[1:])
|
redirect_path = '/'.join(secret_redirect_split[1:])
|
||||||
|
|
||||||
|
if secret == 'denied':
|
||||||
|
# Mother says no
|
||||||
|
return handle_error_code(403)
|
||||||
|
|
||||||
# Ask the mothership if the secret is known to them, to prevent someone from just making up a URL
|
# Ask the mothership if the secret is known to them, to prevent someone from just making up a URL
|
||||||
# Mothership will invalidate this secret token upon handling this request to prevent replay
|
# Mothership will invalidate this secret token upon handling this request to prevent replay
|
||||||
try:
|
try:
|
||||||
@@ -81,18 +104,18 @@ def all_routes(path):
|
|||||||
app.logger.warning('new session aborted, secret "%s" was incorrect, not redirecting to %s', secret, redirect_path)
|
app.logger.warning('new session aborted, secret "%s" was incorrect, not redirecting to %s', secret, redirect_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
app.logger.error('Error while decoding challenge response: %s', str(e))
|
app.logger.error('Error while decoding challenge response: %s', str(e))
|
||||||
return 'Unable to set up session', 403
|
return handle_error_code(403)
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
app.logger.error('lolwtf, server not found: %s', str(e.reason))
|
app.logger.error('lolwtf, server not found: %s', str(e.reason))
|
||||||
return 'Unable to set up session', 403
|
return handle_error_code(403)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
app.logger.error('lolnope, server says no: %s', str(e.reason))
|
app.logger.error('lolnope, server says no: %s', str(e.reason))
|
||||||
return 'Unable to set up session', 403
|
return handle_error_code(403)
|
||||||
|
|
||||||
# Fallback to disallow
|
# Fallback to disallow
|
||||||
return 'Unable to set up session', 403
|
return handle_error_code(403)
|
||||||
|
|
||||||
# check if the users exist or not
|
# Check if the users exist or not
|
||||||
if not session.get('id'):
|
if not session.get('id'):
|
||||||
# Our current URL, to which mothership will redirect back including a sessionstart
|
# Our current URL, to which mothership will redirect back including a sessionstart
|
||||||
original_url = f'{request.host_url}{path}'
|
original_url = f'{request.host_url}{path}'
|
||||||
@@ -101,6 +124,8 @@ def all_routes(path):
|
|||||||
app.logger.info('Redirecting to mothership with %s', original_url)
|
app.logger.info('Redirecting to mothership with %s', original_url)
|
||||||
return redirect(f'{app.config["MOTHERSHIP"]}/login?redirect={original_url}&callback={callback_url}')
|
return redirect(f'{app.config["MOTHERSHIP"]}/login?redirect={original_url}&callback={callback_url}')
|
||||||
|
|
||||||
|
if path == '':
|
||||||
|
path = 'index.html'
|
||||||
file_path = os.path.join(app.config['SERVE_DIR'], path)
|
file_path = os.path.join(app.config['SERVE_DIR'], path)
|
||||||
if os.path.isfile(file_path):
|
if os.path.isfile(file_path):
|
||||||
app.logger.info('Serving file %s', str(file_path))
|
app.logger.info('Serving file %s', str(file_path))
|
||||||
@@ -108,9 +133,4 @@ def all_routes(path):
|
|||||||
return send_from_directory(app.config['SERVE_DIR'], path)
|
return send_from_directory(app.config['SERVE_DIR'], path)
|
||||||
else:
|
else:
|
||||||
app.logger.error('File not found: %s', str(file_path))
|
app.logger.error('File not found: %s', str(file_path))
|
||||||
return 'Sorry, 404'
|
return handle_error_code(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
return redirect('/index.html')
|
|
||||||
|
|||||||
Reference in New Issue
Block a user